@synaplink/orqlaude 0.10.3 → 0.10.7
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 +165 -0
- package/dist/__tests__/v0105.test.d.ts +1 -0
- package/dist/__tests__/v0105.test.js +94 -0
- package/dist/__tests__/v0105.test.js.map +1 -0
- package/dist/__tests__/v0107.test.d.ts +1 -0
- package/dist/__tests__/v0107.test.js +37 -0
- package/dist/__tests__/v0107.test.js.map +1 -0
- package/dist/__tests__/v010_helpers.test.d.ts +1 -0
- package/dist/__tests__/v010_helpers.test.js +301 -0
- package/dist/__tests__/v010_helpers.test.js.map +1 -0
- package/dist/cli/backlog.d.ts +1 -0
- package/dist/cli/backlog.js +318 -0
- package/dist/cli/backlog.js.map +1 -0
- package/dist/cli/memory.d.ts +1 -0
- package/dist/cli/memory.js +245 -0
- package/dist/cli/memory.js.map +1 -0
- package/dist/cli.js +17 -0
- package/dist/cli.js.map +1 -1
- package/dist/lib/orch_turn.d.ts +13 -0
- package/dist/lib/orch_turn.js +2 -2
- package/dist/lib/orch_turn.js.map +1 -1
- package/dist/lib/retry.d.ts +1 -0
- package/dist/lib/retry.js +1 -1
- package/dist/lib/retry.js.map +1 -1
- package/dist/lib/spawn_cli.d.ts +11 -0
- package/dist/lib/spawn_cli.js +14 -1
- package/dist/lib/spawn_cli.js.map +1 -1
- package/dist/lib/version.d.ts +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/tools/broker.js +49 -14
- package/dist/tools/broker.js.map +1 -1
- package/dist/tools/dispatch.js +33 -6
- package/dist/tools/dispatch.js.map +1 -1
- package/dist/tools/userio.js +148 -13
- package/dist/tools/userio.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -183,6 +183,171 @@ User says: *"Refactor the auth system — magic-link login, update the docs, add
|
|
|
183
183
|
8. `orqlaude.collect` → three PR URLs and summaries.
|
|
184
184
|
9. **NEW**: `orqlaude.review_prs(plan_id)` → spawns three reviewer agents, one per PR. Each reviews, runs tests, posts findings. You aggregate the second-round notes.
|
|
185
185
|
|
|
186
|
+
## Asking the user (v0.10.4 pattern)
|
|
187
|
+
|
|
188
|
+
`ask_user` and its companion `wait_for_user_response` are the bounded-block loop primary Claude uses to put a question on Telegram and stay alive past the MCP host's 60s per-request timeout.
|
|
189
|
+
|
|
190
|
+
The split exists because Claude Desktop and Claude Code both use the SDK default `DEFAULT_REQUEST_TIMEOUT_MSEC = 60000`. v0.10.2's progress notifications turned out to be ignored unless the client passes `resetTimeoutOnProgress: true` (it doesn't), so a single blocking call can't outrun the host. Instead, `ask_user` blocks at most 45s (the new `initial_block_sec`, capped at 45). The question's overall lifetime is `total_timeout_sec` (default 900s, max 3600s) -- that's how long it stays answerable. If the user replies inside the first window, you get `status: "answered"`. Otherwise you get `status: "still_pending"` with a `short_id`, and the caller must invoke `wait_for_user_response(short_id)` to keep waiting.
|
|
191
|
+
|
|
192
|
+
Loop pattern (TS pseudocode):
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
let result = await ask_user({
|
|
196
|
+
prompt: "Approve the auth refactor plan?",
|
|
197
|
+
options: ["Approve", "Hold off"],
|
|
198
|
+
total_timeout_sec: 1800,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
while (result.status === "still_pending") {
|
|
202
|
+
result = await wait_for_user_response({ short_id: result.short_id });
|
|
203
|
+
}
|
|
204
|
+
// result.status is now "answered" / "timed_out" / "cancelled"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Each call stays safely under 60s. A fast answer is one round-trip; a 5-minute wait is roughly 7 round-trips, no `ScheduleWakeup`-and-come-back required.
|
|
208
|
+
|
|
209
|
+
Telegram side is plain text only (no Markdown), so escaping bugs can't silently swallow a send. The notifier ships each question with `force_reply` enabled -- the user just types and their reply carries `reply_to_message.message_id`, which the bot matches back to the request. Inline-keyboard buttons fire the same answer path when `options` are provided; `/respond <short_id> <text>` remains as a manual fallback.
|
|
210
|
+
|
|
211
|
+
## Autopilot daemon
|
|
212
|
+
|
|
213
|
+
A persistent orchestrator that ticks every 10 seconds, picks goals off the backlog, auto-reviews PRs, retries failed Agnets, and watches the budget. Opt-in -- nothing runs in the background unless you start it.
|
|
214
|
+
|
|
215
|
+
Five tick-loop phases:
|
|
216
|
+
|
|
217
|
+
1. **Reconcile state** -- for every spawned Agnet, refresh from JSONL, PID, and exit-record; promote `died_at_launch` / `done` / `failed`.
|
|
218
|
+
2. **Recover from failures** -- classify each failure via a Plan-billed `claude -p` turn, then retry with backoff, spawn a debugger Agnet, or escalate to the user via Telegram.
|
|
219
|
+
3. **Auto-review PRs** -- fetch the diff, run a reviewer turn, apply the fleet template's auto-merge rule, and either `gh pr merge` or `gh pr comment`.
|
|
220
|
+
4. **Pick the next goal** -- when the fleet is idle and autopilot is unpaused, pull the highest-priority unblocked goal from the backlog and prompt the user via Telegram.
|
|
221
|
+
5. **Watch the budget** -- yellow / orange / red thresholds; auto-pause at orange.
|
|
222
|
+
|
|
223
|
+
CLI:
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
orql autopilot start # foreground; daemonize with launchd / systemd / nohup
|
|
227
|
+
orql autopilot stop
|
|
228
|
+
orql autopilot pause # stop picking new work; in-flight Agnets keep running
|
|
229
|
+
orql autopilot resume
|
|
230
|
+
orql autopilot status
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Plan-billing note: the daemon **never** talks to the Anthropic API. Every intelligent decision (failure classifier, PR reviewer, Telegram intent classifier, template suggester) is a `claude -p` invocation. On the Max plan that bills like an interactive Claude Code session, and cache reads are free, so a full day of ticking burns a tiny fraction of quota.
|
|
234
|
+
|
|
235
|
+
## Durable memory
|
|
236
|
+
|
|
237
|
+
A `memory.json` file at `<state_dir>/memory.json` holds long-lived facts that outlive plan lifecycles. Four spirit-themed categories, each with a different surfacing rule:
|
|
238
|
+
|
|
239
|
+
- **lore** -- facts about the user. Pinned, slow churn, injected into every spawned Agnet prompt. _Example: "Russian comments in CRM templates", "no auto-deploy on Fridays."_
|
|
240
|
+
- **playbook** -- code conventions. Scope-tagged by path-glob; injected when a fleet's scope overlaps. _Example: "migrations live in `<app>/migrations/`", "use AntD ConfigProvider for dark mode."_
|
|
241
|
+
- **ledger** -- decisions plus rationale. Append-only; surfaced when a similar decision recurs. _Example: "Sonnet over Opus for transcription, latency mattered more than depth."_
|
|
242
|
+
- **atlas** -- project map. Auto-updated by the post-PR review with one entry per touched file mapping path to purpose.
|
|
243
|
+
|
|
244
|
+
Pinned entries always load. Scope-tagged entries (typically playbook and atlas) auto-inject into a spawn prompt when the Agnet's worktree scope matches any of the entry's globs -- you don't have to remember to thread conventions through manually.
|
|
245
|
+
|
|
246
|
+
MCP tools:
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
remember(category, key, value, { pinned?, scopeGlobs?, rationale? })
|
|
250
|
+
recall(category?, key?, scopeMatch?)
|
|
251
|
+
forget(category, key)
|
|
252
|
+
compose_memory_context(scopeGlobs?, max_tokens?)
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Older entries with the same `(category, key)` are soft-superseded: kept for history but invisible to read paths. `compose_memory_context` is the function the spawn pipeline uses internally; call it directly to preview the block that will be injected for a given scope before you commit to spawning.
|
|
256
|
+
|
|
257
|
+
## Backlog
|
|
258
|
+
|
|
259
|
+
A `backlog.json` file holds `Goal` records -- durable task descriptions the daemon (or you) can pick from when idle.
|
|
260
|
+
|
|
261
|
+
Shape:
|
|
262
|
+
|
|
263
|
+
```json
|
|
264
|
+
{
|
|
265
|
+
"id": "g_8f3...",
|
|
266
|
+
"title": "Rotate JWT signing keys quarterly",
|
|
267
|
+
"priority": 70,
|
|
268
|
+
"deadlineAt": "2026-06-30T00:00:00Z",
|
|
269
|
+
"dependsOn": ["g_5c1..."],
|
|
270
|
+
"createdAt": "2026-05-12T10:14:00Z",
|
|
271
|
+
"status": "pending"
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Priority is 0-100. `deadlineAt` boosts effective priority as the deadline approaches (linear ramp over the last 14 days, so something due tomorrow with priority 40 beats a no-deadline priority 70 item). `dependsOn` is a list of goal ids; a goal is blocked until every parent has `status: "done"`.
|
|
276
|
+
|
|
277
|
+
MCP tools:
|
|
278
|
+
|
|
279
|
+
```
|
|
280
|
+
enqueue_goal(title, { priority?, deadlineAt?, dependsOn?, source? })
|
|
281
|
+
list_goals({ status?, includeBlocked? })
|
|
282
|
+
update_goal(id, { priority?, deadlineAt?, dependsOn?, status? })
|
|
283
|
+
pick_next_goal()
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`pick_next_goal` returns the highest-priority unblocked pending goal, factoring in deadline boost. The autopilot daemon calls this on every idle tick; when something comes back it surfaces the goal to the user via Telegram for confirmation before spawning a fleet, so you keep approval-in-the-loop even when the orchestrator is running unattended.
|
|
287
|
+
|
|
288
|
+
## Fleet templates
|
|
289
|
+
|
|
290
|
+
Eight named patterns ship out of the box. Each defines a default Agnet layout, a suggested model per role (haiku / sonnet / opus), a default budget, and an `AutoMergeRule` the daemon applies to PRs produced by the fleet.
|
|
291
|
+
|
|
292
|
+
| id | what it does | auto-merge rule |
|
|
293
|
+
|---|---|---|
|
|
294
|
+
| `backend-feature` | Django/DRF: model + migration + serializer + viewset + admin + tests | requireCi, maxLoc 2500 |
|
|
295
|
+
| `frontend-feature` | React/AntD: components + hooks + i18n + tests | requireCi, maxLoc 2000 |
|
|
296
|
+
| `migration-only` | Schema change with backwards-compat reviewer (opus) | requireReviewerApprove; `blockOnMigrations: false` (migrations are the point) |
|
|
297
|
+
| `audit-sweep` | Multiple haiku auditors + sonnet synthesizer (read-only) | requireReviewerApprove (no merge) |
|
|
298
|
+
| `dep-upgrade` | Dep version bump + breaking-change patches + reviewer | requireCi, requireReviewerApprove |
|
|
299
|
+
| `i18n-pass` | Audit, then translator pass | requireCi, maxLoc 3000 |
|
|
300
|
+
| `test-coverage-fill` | Parallel testers; blocks PRs that touch prod code | requireCi, blockOnPaths globs for non-test files |
|
|
301
|
+
| `bug-hunt` | Reproducer Agnet then fixer Agnet (sequential) | requireReviewerApprove, requireCi |
|
|
302
|
+
|
|
303
|
+
MCP tools:
|
|
304
|
+
|
|
305
|
+
```
|
|
306
|
+
list_fleet_templates()
|
|
307
|
+
suggest_fleet_template(goal_text) # Plan-billed turn picks the best fit
|
|
308
|
+
apply_fleet_template(template_id, { goal, scope, budget_override? })
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
`suggest_fleet_template` makes a single `claude -p` call that returns `{ template_id, confidence, reason }`. The autopilot daemon uses this to turn a freeform goal description into a concrete fleet definition without manual plan authoring. `apply_fleet_template` then expands the chosen template into a real plan via `create_plan`, with the template's auto-merge rule attached for later use by the auto-review pipeline.
|
|
312
|
+
|
|
313
|
+
## Auto-PR-review
|
|
314
|
+
|
|
315
|
+
When the autopilot daemon is running, every PR produced by a template-driven fleet gets a reviewer turn and an auto-merge attempt.
|
|
316
|
+
|
|
317
|
+
The reviewer turn runs `gh pr view` for the diff and metadata, feeds them into a strict-JSON `claude -p` prompt, and parses the response `{ verdict, blockers, suggestions, summary }` where `verdict` is `APPROVE` / `REQUEST_CHANGES` / `COMMENT`. The summary is appended as a PR comment regardless of verdict so you have a paper trail.
|
|
318
|
+
|
|
319
|
+
The fleet template's `AutoMergeRule` then decides whether to merge:
|
|
320
|
+
|
|
321
|
+
```json
|
|
322
|
+
{
|
|
323
|
+
"requireReviewerApprove": true,
|
|
324
|
+
"requireCi": true,
|
|
325
|
+
"maxLoc": 2500,
|
|
326
|
+
"blockOnMigrations": false,
|
|
327
|
+
"blockOnPaths": ["**/settings.py", "**/secrets/**"]
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
- `requireReviewerApprove` -- the reviewer's verdict must be `APPROVE`.
|
|
332
|
+
- `requireCi` -- `gh pr checks` must come back all-green.
|
|
333
|
+
- `maxLoc` -- additions plus deletions under cap. Larger PRs route to user.
|
|
334
|
+
- `blockOnMigrations` -- refuses PRs that add files under `*/migrations/` (used by templates that aren't supposed to touch schema).
|
|
335
|
+
- `blockOnPaths` -- refuses PRs touching specific globs.
|
|
336
|
+
|
|
337
|
+
If every check passes the daemon runs `gh pr merge --squash --auto --delete-branch`. Otherwise it `gh pr comment`s with the verdict and blockers and leaves the PR open. Each review writes a `ledger` memory entry so the next fleet inherits the rationale.
|
|
338
|
+
|
|
339
|
+
## Cost guardrails
|
|
340
|
+
|
|
341
|
+
A `guardrails.json` rolling ledger tracks billed tokens against two windows: a 5-hour rolling window (matching Anthropic's Plan reset cadence) and a per-local-day soft cap (default 30M billed tokens).
|
|
342
|
+
|
|
343
|
+
Three threshold bands on the rolling window:
|
|
344
|
+
|
|
345
|
+
- **yellow** at 60% -- notify the user, daemon slows inter-tick interval.
|
|
346
|
+
- **orange** at 80% -- daemon auto-pauses; refuses to start new fleets; in-flight Agnets keep running but no new spawns happen.
|
|
347
|
+
- **red** at 95% -- halt entirely; await user `/resume` after the next 5h reset.
|
|
348
|
+
|
|
349
|
+
The day soft-cap applies independently of the rolling window -- you can sit comfortably in green on the 5h window and still cross the day cap if you've been running multiple windows back to back. Both checks happen on every autopilot tick, and the orange auto-pause uses the same code path as `orql autopilot pause`: it surfaces in `orql autopilot status` and is undone with `orql autopilot resume` once the window has rolled.
|
|
350
|
+
|
|
186
351
|
## State
|
|
187
352
|
|
|
188
353
|
orqlaude resolves its state directory at startup using this order (first match wins):
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
/**
|
|
7
|
+
* v0.10.5 — spawn_via_cli pre-allocates session_id and embeds it in the
|
|
8
|
+
* fleet protocol prompt + checkin handler accepts rotation for freshly
|
|
9
|
+
* spawned tasks.
|
|
10
|
+
*
|
|
11
|
+
* Bug it fixes: orqlaude self-test fleet d47c0448 — Verdant tried to
|
|
12
|
+
* checkin with $CLAUDE_CODE_SESSION_ID (Claude Code's internal value)
|
|
13
|
+
* which differed from the --session-id flag orqlaude passed. The
|
|
14
|
+
* pre-allocation had already filled task.spawnedSessionId so
|
|
15
|
+
* unclaimedTaskById returned undefined, falling through to
|
|
16
|
+
* task_already_claimed rejection.
|
|
17
|
+
*/
|
|
18
|
+
async function tempDir(label) {
|
|
19
|
+
return fs.mkdtemp(path.join(os.tmpdir(), `orqlaude-v0105-${label}-`));
|
|
20
|
+
}
|
|
21
|
+
test("v0.10.5: spawn_via_cli sessionId override flows through to --session-id flag", async () => {
|
|
22
|
+
// Verify the SpawnViaCliInput.sessionId field is honored. We can't actually
|
|
23
|
+
// spawn claude in unit tests (no binary), but we can confirm the type +
|
|
24
|
+
// pass-through logic by reading the source.
|
|
25
|
+
// import.meta.dirname under built dist/__tests__/ → ../../src/lib/spawn_cli.ts
|
|
26
|
+
// import.meta.dirname under built dist/__tests__/ → ../../src/lib/spawn_cli.ts
|
|
27
|
+
const src = await fs.readFile(path.join(import.meta.dirname, "..", "..", "src", "lib", "spawn_cli.ts"), "utf8");
|
|
28
|
+
// The fallback `?? randomUUID()` should be present so omitted sessionId
|
|
29
|
+
// still produces a uuid.
|
|
30
|
+
assert.ok(src.includes("input.sessionId ?? randomUUID()"), "sessionId fallback wiring missing");
|
|
31
|
+
assert.ok(src.includes("sessionId?:"), "sessionId field missing from SpawnViaCliInput");
|
|
32
|
+
});
|
|
33
|
+
test("v0.10.5: checkin accepts session-id rotation for freshly spawned tasks", async () => {
|
|
34
|
+
// Simulate the v0.10.4-and-earlier failure mode + verify v0.10.5 handles it.
|
|
35
|
+
const dir = await tempDir("checkin-rotate");
|
|
36
|
+
// Build a minimal state file with a pre-allocated spawnedSessionId.
|
|
37
|
+
const stateFile = path.join(dir, "orqlaude-state.json");
|
|
38
|
+
const planId = "plan-test-id-fixed";
|
|
39
|
+
const taskId = "task-test-id-fixed";
|
|
40
|
+
const preallocatedSessionId = "preallocated-session-id";
|
|
41
|
+
const agentSessionId = "agent-different-session-id";
|
|
42
|
+
const justStartedAt = Date.now() - 5_000; // 5s ago - within 60s grace
|
|
43
|
+
await fs.writeFile(stateFile, JSON.stringify({
|
|
44
|
+
schemaVersion: 3,
|
|
45
|
+
plans: {
|
|
46
|
+
[planId]: {
|
|
47
|
+
id: planId,
|
|
48
|
+
createdAt: Date.now(),
|
|
49
|
+
rootTask: "test",
|
|
50
|
+
budgetCapTokens: 1000,
|
|
51
|
+
perAgentCapTokens: 1000,
|
|
52
|
+
status: "running",
|
|
53
|
+
tasks: [
|
|
54
|
+
{
|
|
55
|
+
id: taskId,
|
|
56
|
+
title: "t",
|
|
57
|
+
prompt: "p",
|
|
58
|
+
tldr: "t",
|
|
59
|
+
status: "running",
|
|
60
|
+
spawnedSessionId: preallocatedSessionId,
|
|
61
|
+
startedAt: justStartedAt,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
notes: [],
|
|
65
|
+
messages: [],
|
|
66
|
+
claims: [],
|
|
67
|
+
userNotifications: [],
|
|
68
|
+
userResponseRequests: [],
|
|
69
|
+
userStreams: [],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}));
|
|
73
|
+
// We can't easily call the registered tool callback directly (it's wrapped
|
|
74
|
+
// through MCP). But we CAN verify the broker.ts source contains the
|
|
75
|
+
// rotation logic by reading it.
|
|
76
|
+
const brokerSrc = await fs.readFile(path.join(import.meta.dirname, "..", "..", "src", "tools", "broker.ts"), "utf8");
|
|
77
|
+
assert.ok(brokerSrc.includes("wasJustSpawned"), "broker.ts should have the wasJustSpawned rotation check");
|
|
78
|
+
assert.ok(brokerSrc.includes("noNotesYet"), "broker.ts should also gate rotation on no-notes-yet");
|
|
79
|
+
assert.ok(brokerSrc.includes("60_000"), "broker.ts should use 60s grace window for rotation");
|
|
80
|
+
});
|
|
81
|
+
test("v0.10.5: buildSpawnPrompt embeds session_id when provided", async () => {
|
|
82
|
+
// Pure source-level check that the prompt builder honors the sessionId arg.
|
|
83
|
+
const dispatchSrc = await fs.readFile(path.join(import.meta.dirname, "..", "..", "src", "tools", "dispatch.ts"), "utf8");
|
|
84
|
+
assert.ok(dispatchSrc.includes("sessionId?: string"), "buildSpawnPrompt should accept optional sessionId");
|
|
85
|
+
assert.ok(dispatchSrc.includes("EXACT value, pre-allocated by orqlaude"), "When sessionId is provided, the prompt should instruct the agent to use it exactly");
|
|
86
|
+
assert.ok(dispatchSrc.includes("NOT $CLAUDE_CODE_SESSION_ID"), "The protocol prompt should explicitly tell the agent NOT to use the env var");
|
|
87
|
+
});
|
|
88
|
+
test("v0.10.5: spawn_via_cli handler pre-generates session_id before buildSpawnPrompt", async () => {
|
|
89
|
+
const dispatchSrc = await fs.readFile(path.join(import.meta.dirname, "..", "..", "src", "tools", "dispatch.ts"), "utf8");
|
|
90
|
+
assert.ok(dispatchSrc.includes("presetSessionId = randomUUID()"), "spawn_via_cli handler should pre-allocate session_id with randomUUID");
|
|
91
|
+
assert.ok(/buildSpawnPrompt\([^)]*presetSessionId\)/.test(dispatchSrc), "presetSessionId should be passed into buildSpawnPrompt");
|
|
92
|
+
assert.ok(/sessionId:\s*presetSessionId/.test(dispatchSrc), "presetSessionId should be passed into spawnAgnetViaCli as sessionId");
|
|
93
|
+
});
|
|
94
|
+
//# sourceMappingURL=v0105.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"v0105.test.js","sourceRoot":"","sources":["../../src/__tests__/v0105.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* v0.10.7 — re-spawn hygiene.
|
|
7
|
+
*
|
|
8
|
+
* Bugs surfaced during the Verdant re-spawn in self-test fleet d47c0448:
|
|
9
|
+
*
|
|
10
|
+
* 1. `.orqlaude.exit.json` from a prior agent stayed on disk and made
|
|
11
|
+
* `snapshot()` report the new agent as already terminated.
|
|
12
|
+
* 2. `task.stopRequested` set by kill_task survived re-spawn, so the
|
|
13
|
+
* new agent's first checkin received a stale HARD STOP and bailed.
|
|
14
|
+
*
|
|
15
|
+
* Both fixed via source-level surgery. These tests verify the surgery is
|
|
16
|
+
* present (the actual spawn path needs an integration test with claude).
|
|
17
|
+
*/
|
|
18
|
+
test("v0.10.7: spawn_cli unlinks stale .orqlaude.exit.json before spawn", async () => {
|
|
19
|
+
const src = await fs.readFile(path.join(import.meta.dirname, "..", "..", "src", "lib", "spawn_cli.ts"), "utf8");
|
|
20
|
+
assert.ok(src.includes("exitJsonPathPre"), "spawn_cli should pre-unlink the exit json with a named variable");
|
|
21
|
+
assert.ok(/fs\.unlink\(exitJsonPathPre\)/.test(src), "spawn_cli should actually attempt to unlink the prior exit json");
|
|
22
|
+
assert.ok(src.indexOf("exitJsonPathPre") < src.indexOf("spawn(claudeBin"), "the pre-unlink must happen BEFORE the new process is spawned");
|
|
23
|
+
});
|
|
24
|
+
test("v0.10.7: spawn_via_cli handler clears task.stopRequested on re-spawn", async () => {
|
|
25
|
+
const src = await fs.readFile(path.join(import.meta.dirname, "..", "..", "src", "tools", "dispatch.ts"), "utf8");
|
|
26
|
+
assert.ok(src.includes("task.stopRequested = undefined"), "spawn_via_cli should reset stopRequested when claiming a previously-killed task");
|
|
27
|
+
assert.ok(src.includes("task.finishedAt = undefined"), "spawn_via_cli should also clear finishedAt so status() doesn't think the new run is terminated");
|
|
28
|
+
assert.ok(src.includes("task.exitReason = undefined"), "spawn_via_cli should also clear exitReason from the prior run");
|
|
29
|
+
});
|
|
30
|
+
test("v0.10.7: clears happen AFTER setting new spawnedSessionId / pid", async () => {
|
|
31
|
+
const src = await fs.readFile(path.join(import.meta.dirname, "..", "..", "src", "tools", "dispatch.ts"), "utf8");
|
|
32
|
+
const idxSpawnedId = src.indexOf("task.spawnedSessionId = spawn.sessionId");
|
|
33
|
+
const idxStopClear = src.indexOf("task.stopRequested = undefined");
|
|
34
|
+
assert.ok(idxSpawnedId > 0 && idxStopClear > 0, "both lines must be present");
|
|
35
|
+
assert.ok(idxStopClear > idxSpawnedId, "clearing stopRequested must come AFTER setting the new spawnedSessionId to avoid losing the new claim if a concurrent reader sees the intermediate state");
|
|
36
|
+
});
|
|
37
|
+
//# sourceMappingURL=v0107.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"v0107.test.js","sourceRoot":"","sources":["../../src/__tests__/v0107.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,SAAS,CAAC;AACzC,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B;;;;;;;;;;;;GAYG;AAEH,IAAI,CAAC,mEAAmE,EAAE,KAAK,IAAI,EAAE;IACnF,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,QAAQ,CAC3B,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,cAAc,CAAC,EACxE,MAAM,CACP,CAAC;IACF,MAAM,CAAC,EAAE,CACP,GAAG,CAAC,QAAQ,CAAC,iBAAiB,CAAC,EAC/B,iEAAiE,CAClE,CAAC;IACF,MAAM,CAAC,EAAE,CACP,+BAA+B,CAAC,IAAI,CAAC,GAAG,CAAC,EACzC,iEAAiE,CAClE,CAAC;IACF,MAAM,CAAC,EAAE,CACP,GAAG,CAAC,OAAO,CAAC,iBAAiB,CAAC,GAAG,GAAG,CAAC,OAAO,CAAC,iBAAiB,CAAC,EAC/D,8DAA8D,CAC/D,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,sEAAsE,EAAE,KAAK,IAAI,EAAE;IACtF,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,QAAQ,CAC3B,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,CAAC,EACzE,MAAM,CACP,CAAC;IACF,MAAM,CAAC,EAAE,CACP,GAAG,CAAC,QAAQ,CAAC,gCAAgC,CAAC,EAC9C,iFAAiF,CAClF,CAAC;IACF,MAAM,CAAC,EAAE,CACP,GAAG,CAAC,QAAQ,CAAC,6BAA6B,CAAC,EAC3C,gGAAgG,CACjG,CAAC;IACF,MAAM,CAAC,EAAE,CACP,GAAG,CAAC,QAAQ,CAAC,6BAA6B,CAAC,EAC3C,+DAA+D,CAChE,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,iEAAiE,EAAE,KAAK,IAAI,EAAE;IACjF,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,QAAQ,CAC3B,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,CAAC,EACzE,MAAM,CACP,CAAC;IACF,MAAM,YAAY,GAAG,GAAG,CAAC,OAAO,CAAC,yCAAyC,CAAC,CAAC;IAC5E,MAAM,YAAY,GAAG,GAAG,CAAC,OAAO,CAAC,gCAAgC,CAAC,CAAC;IACnE,MAAM,CAAC,EAAE,CAAC,YAAY,GAAG,CAAC,IAAI,YAAY,GAAG,CAAC,EAAE,4BAA4B,CAAC,CAAC;IAC9E,MAAM,CAAC,EAAE,CACP,YAAY,GAAG,YAAY,EAC3B,0JAA0J,CAC3J,CAAC;AACJ,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { tryParseJson, extractAssistantText } from "../lib/orch_turn.js";
|
|
7
|
+
import { classifyFailure, countRetries, readTail, DEFAULT_RETRY } from "../lib/retry.js";
|
|
8
|
+
import { applyRule } from "../lib/auto_merge.js";
|
|
9
|
+
import { parseSlashCommand } from "../lib/tg_classifier.js";
|
|
10
|
+
/**
|
|
11
|
+
* v0.10.x helper coverage - JSON extraction, retry classifier fast paths,
|
|
12
|
+
* auto-merge edge cases, slash-command parser corners. Sibling to v010.test.ts;
|
|
13
|
+
* these focus on internal helpers and edge cases the higher-level tests skip.
|
|
14
|
+
*/
|
|
15
|
+
async function tempDir(label) {
|
|
16
|
+
return fs.mkdtemp(path.join(os.tmpdir(), `orqlaude-v010h-${label}-`));
|
|
17
|
+
}
|
|
18
|
+
// ---- orch_turn.tryParseJson ---------------------------------------------
|
|
19
|
+
test("v0.10.x: tryParseJson parses plain JSON object", () => {
|
|
20
|
+
const r = tryParseJson('{"a":1,"b":"two"}');
|
|
21
|
+
assert.equal(r.ok, true);
|
|
22
|
+
if (r.ok)
|
|
23
|
+
assert.deepEqual(r.value, { a: 1, b: "two" });
|
|
24
|
+
});
|
|
25
|
+
test("v0.10.x: tryParseJson strips ```json ... ``` fences", () => {
|
|
26
|
+
const r = tryParseJson('```json\n{"action":"retry"}\n```');
|
|
27
|
+
assert.equal(r.ok, true);
|
|
28
|
+
if (r.ok)
|
|
29
|
+
assert.deepEqual(r.value, { action: "retry" });
|
|
30
|
+
});
|
|
31
|
+
test("v0.10.x: tryParseJson strips bare ``` ... ``` fences", () => {
|
|
32
|
+
const r = tryParseJson('```\n{"x":42}\n```');
|
|
33
|
+
assert.equal(r.ok, true);
|
|
34
|
+
if (r.ok)
|
|
35
|
+
assert.deepEqual(r.value, { x: 42 });
|
|
36
|
+
});
|
|
37
|
+
test("v0.10.x: tryParseJson lifts the first {...} out of surrounding prose", () => {
|
|
38
|
+
const text = 'Sure, here is the JSON you asked for:\n{"verdict":"APPROVE","blockers":[]}\nLet me know if you need more.';
|
|
39
|
+
const r = tryParseJson(text);
|
|
40
|
+
assert.equal(r.ok, true);
|
|
41
|
+
if (r.ok)
|
|
42
|
+
assert.deepEqual(r.value, { verdict: "APPROVE", blockers: [] });
|
|
43
|
+
});
|
|
44
|
+
test("v0.10.x: tryParseJson handles nested objects (depth tracking)", () => {
|
|
45
|
+
const text = 'reasoning... {"outer":{"inner":{"k":1}},"arr":[1,2,3]} trailing prose';
|
|
46
|
+
const r = tryParseJson(text);
|
|
47
|
+
assert.equal(r.ok, true);
|
|
48
|
+
if (r.ok)
|
|
49
|
+
assert.deepEqual(r.value, { outer: { inner: { k: 1 } }, arr: [1, 2, 3] });
|
|
50
|
+
});
|
|
51
|
+
test("v0.10.x: tryParseJson returns ok:false with error for empty input", () => {
|
|
52
|
+
const r = tryParseJson("");
|
|
53
|
+
assert.equal(r.ok, false);
|
|
54
|
+
if (!r.ok)
|
|
55
|
+
assert.match(r.error, /empty/i);
|
|
56
|
+
});
|
|
57
|
+
test("v0.10.x: tryParseJson returns ok:false when no '{' present", () => {
|
|
58
|
+
const r = tryParseJson("just some prose, no JSON to be found here");
|
|
59
|
+
assert.equal(r.ok, false);
|
|
60
|
+
if (!r.ok)
|
|
61
|
+
assert.match(r.error, /no JSON object/i);
|
|
62
|
+
});
|
|
63
|
+
test("v0.10.x: tryParseJson reports parse failure with offset for malformed object", () => {
|
|
64
|
+
const r = tryParseJson('{"a": 1, "b": ,}');
|
|
65
|
+
assert.equal(r.ok, false);
|
|
66
|
+
if (!r.ok)
|
|
67
|
+
assert.match(r.error, /JSON parse failed|offset/i);
|
|
68
|
+
});
|
|
69
|
+
test("v0.10.x: tryParseJson reports unbalanced braces", () => {
|
|
70
|
+
const r = tryParseJson('{"a": 1');
|
|
71
|
+
assert.equal(r.ok, false);
|
|
72
|
+
if (!r.ok)
|
|
73
|
+
assert.ok(r.error.length > 0);
|
|
74
|
+
});
|
|
75
|
+
// ---- orch_turn.extractAssistantText -------------------------------------
|
|
76
|
+
test("v0.10.x: extractAssistantText concatenates assistant.message.content text blocks", () => {
|
|
77
|
+
const env = { type: "assistant", message: { content: [{ type: "text", text: "hello " }, { type: "text", text: "world" }] } };
|
|
78
|
+
const out = extractAssistantText(JSON.stringify(env));
|
|
79
|
+
assert.equal(out, "hello world");
|
|
80
|
+
});
|
|
81
|
+
test("v0.10.x: extractAssistantText handles {type:'text', text:'...'} envelope", () => {
|
|
82
|
+
const out = extractAssistantText(JSON.stringify({ type: "text", text: "plain text envelope" }));
|
|
83
|
+
assert.equal(out, "plain text envelope");
|
|
84
|
+
});
|
|
85
|
+
test("v0.10.x: extractAssistantText handles {type:'message_delta', delta:{text:'...'}} envelope", () => {
|
|
86
|
+
const lines = [
|
|
87
|
+
JSON.stringify({ type: "message_delta", delta: { text: "part one " } }),
|
|
88
|
+
JSON.stringify({ type: "message_delta", delta: { text: "part two" } }),
|
|
89
|
+
].join("\n");
|
|
90
|
+
assert.equal(extractAssistantText(lines), "part one part two");
|
|
91
|
+
});
|
|
92
|
+
test("v0.10.x: extractAssistantText handles {type:'result', result:'...'} envelope", () => {
|
|
93
|
+
const out = extractAssistantText(JSON.stringify({ type: "result", result: "final answer" }));
|
|
94
|
+
assert.equal(out, "final answer");
|
|
95
|
+
});
|
|
96
|
+
test("v0.10.x: extractAssistantText skips lines that aren't valid JSON", () => {
|
|
97
|
+
const stdout = [
|
|
98
|
+
"not json at all",
|
|
99
|
+
JSON.stringify({ type: "text", text: "ok" }),
|
|
100
|
+
"{not valid json either",
|
|
101
|
+
].join("\n");
|
|
102
|
+
assert.equal(extractAssistantText(stdout), "ok");
|
|
103
|
+
});
|
|
104
|
+
test("v0.10.x: extractAssistantText returns empty string for blank stdout", () => {
|
|
105
|
+
assert.equal(extractAssistantText(""), "");
|
|
106
|
+
assert.equal(extractAssistantText(" \n\n "), "");
|
|
107
|
+
});
|
|
108
|
+
test("v0.10.x: extractAssistantText ignores envelope shapes it doesn't recognize", () => {
|
|
109
|
+
const stdout = [
|
|
110
|
+
JSON.stringify({ type: "system", subtype: "init" }),
|
|
111
|
+
JSON.stringify({ type: "tool_use", name: "x" }),
|
|
112
|
+
JSON.stringify({ type: "text", text: "kept" }),
|
|
113
|
+
].join("\n");
|
|
114
|
+
assert.equal(extractAssistantText(stdout), "kept");
|
|
115
|
+
});
|
|
116
|
+
test("v0.10.x: extractAssistantText concatenates a mixed stream", () => {
|
|
117
|
+
const stdout = [
|
|
118
|
+
JSON.stringify({ type: "system", subtype: "init" }),
|
|
119
|
+
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "A" }] } }),
|
|
120
|
+
JSON.stringify({ type: "text", text: "B" }),
|
|
121
|
+
JSON.stringify({ type: "message_delta", delta: { text: "C" } }),
|
|
122
|
+
JSON.stringify({ type: "result", result: "D" }),
|
|
123
|
+
].join("\n");
|
|
124
|
+
assert.equal(extractAssistantText(stdout), "ABCD");
|
|
125
|
+
});
|
|
126
|
+
// ---- retry.classifyFailure (fast path) ----------------------------------
|
|
127
|
+
function makeTask(overrides = {}) {
|
|
128
|
+
const base = {
|
|
129
|
+
id: "task-id",
|
|
130
|
+
title: "T",
|
|
131
|
+
prompt: "p",
|
|
132
|
+
tldr: "t",
|
|
133
|
+
status: "died_at_launch",
|
|
134
|
+
};
|
|
135
|
+
return { ...base, ...overrides };
|
|
136
|
+
}
|
|
137
|
+
test("v0.10.x: classifyFailure fast-path retries when died_at_launch + retries < cap", async () => {
|
|
138
|
+
const task = makeTask({ status: "died_at_launch", summary: "[retry 0/2]" });
|
|
139
|
+
const decision = await classifyFailure(task, "", "", DEFAULT_RETRY);
|
|
140
|
+
assert.equal(decision.action, "retry");
|
|
141
|
+
assert.equal(decision.retryAfterMs, DEFAULT_RETRY.retryBackoffMs);
|
|
142
|
+
assert.match(decision.reason, /1\/2/);
|
|
143
|
+
});
|
|
144
|
+
test("v0.10.x: classifyFailure fast-path retries on first failure (no [retry] marker)", async () => {
|
|
145
|
+
const task = makeTask({ status: "died_at_launch" });
|
|
146
|
+
const decision = await classifyFailure(task, "", "", DEFAULT_RETRY);
|
|
147
|
+
assert.equal(decision.action, "retry");
|
|
148
|
+
});
|
|
149
|
+
test("v0.10.x: classifyFailure escalates when died_at_launch + retries >= cap", async () => {
|
|
150
|
+
const task = makeTask({ status: "died_at_launch", summary: "[retry 2/2]" });
|
|
151
|
+
const decision = await classifyFailure(task, "", "", DEFAULT_RETRY);
|
|
152
|
+
assert.equal(decision.action, "escalate");
|
|
153
|
+
assert.match(decision.reason, /Exhausted/i);
|
|
154
|
+
});
|
|
155
|
+
test("v0.10.x: classifyFailure treats failed-without-startedAt as launch death", async () => {
|
|
156
|
+
const task = makeTask({ status: "failed", startedAt: undefined });
|
|
157
|
+
const decision = await classifyFailure(task, "", "", DEFAULT_RETRY);
|
|
158
|
+
assert.equal(decision.action, "retry");
|
|
159
|
+
});
|
|
160
|
+
test("v0.10.x: classifyFailure respects custom maxDiedAtLaunchRetries cap", async () => {
|
|
161
|
+
const cfg = { ...DEFAULT_RETRY, maxDiedAtLaunchRetries: 1 };
|
|
162
|
+
const taskUnder = makeTask({ status: "died_at_launch", summary: "[retry 0/1]" });
|
|
163
|
+
const taskOver = makeTask({ status: "died_at_launch", summary: "[retry 1/1]" });
|
|
164
|
+
assert.equal((await classifyFailure(taskUnder, "", "", cfg)).action, "retry");
|
|
165
|
+
assert.equal((await classifyFailure(taskOver, "", "", cfg)).action, "escalate");
|
|
166
|
+
});
|
|
167
|
+
// ---- retry.countRetries -------------------------------------------------
|
|
168
|
+
test("v0.10.x: countRetries reads N from '[retry N/M]' summary marker", () => {
|
|
169
|
+
assert.equal(countRetries(makeTask({ summary: "[retry 0/2]" })), 0);
|
|
170
|
+
assert.equal(countRetries(makeTask({ summary: "did things [retry 3/5] more text" })), 3);
|
|
171
|
+
});
|
|
172
|
+
test("v0.10.x: countRetries returns 0 when no marker present", () => {
|
|
173
|
+
assert.equal(countRetries(makeTask({ summary: undefined })), 0);
|
|
174
|
+
assert.equal(countRetries(makeTask({ summary: "no marker here" })), 0);
|
|
175
|
+
});
|
|
176
|
+
// ---- retry.readTail -----------------------------------------------------
|
|
177
|
+
test("v0.10.x: readTail returns empty string for undefined path", async () => {
|
|
178
|
+
assert.equal(await readTail(undefined, 1024), "");
|
|
179
|
+
});
|
|
180
|
+
test("v0.10.x: readTail returns empty string for missing file", async () => {
|
|
181
|
+
const dir = await tempDir("readtail-missing");
|
|
182
|
+
const out = await readTail(path.join(dir, "does-not-exist.log"), 1024);
|
|
183
|
+
assert.equal(out, "");
|
|
184
|
+
});
|
|
185
|
+
test("v0.10.x: readTail returns last N bytes of an existing file", async () => {
|
|
186
|
+
const dir = await tempDir("readtail-ok");
|
|
187
|
+
const file = path.join(dir, "log.txt");
|
|
188
|
+
await fs.writeFile(file, "0123456789ABCDEF"); // 16 bytes
|
|
189
|
+
assert.equal(await readTail(file, 4), "CDEF");
|
|
190
|
+
assert.equal(await readTail(file, 100), "0123456789ABCDEF"); // larger than file
|
|
191
|
+
});
|
|
192
|
+
// ---- auto_merge.applyRule edge cases ------------------------------------
|
|
193
|
+
function makePr(overrides = {}) {
|
|
194
|
+
return {
|
|
195
|
+
number: 1,
|
|
196
|
+
url: "https://github.com/x/y/pull/1",
|
|
197
|
+
state: "OPEN",
|
|
198
|
+
title: "t",
|
|
199
|
+
author: "a",
|
|
200
|
+
headBranch: "h",
|
|
201
|
+
baseBranch: "b",
|
|
202
|
+
additions: 10,
|
|
203
|
+
deletions: 5,
|
|
204
|
+
changedFiles: 1,
|
|
205
|
+
files: [{ path: "src/foo.ts", additions: 10, deletions: 5 }],
|
|
206
|
+
checksStatus: "success",
|
|
207
|
+
mergeable: true,
|
|
208
|
+
...overrides,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function makeReview(overrides = {}) {
|
|
212
|
+
return { verdict: "APPROVE", blockers: [], suggestions: [], summary: "ok", ...overrides };
|
|
213
|
+
}
|
|
214
|
+
test("v0.10.x: applyRule with all-undefined rule still enforces defaults (CI + reviewer)", () => {
|
|
215
|
+
// Defaults: requireReviewerApprove !== false → enforced. requireCi !== false → enforced.
|
|
216
|
+
// A green PR with APPROVE should still pass.
|
|
217
|
+
const d = applyRule(makePr(), makeReview(), {});
|
|
218
|
+
assert.equal(d.ok, true);
|
|
219
|
+
assert.equal(d.violations.length, 0);
|
|
220
|
+
});
|
|
221
|
+
test("v0.10.x: applyRule requireCi:false ignores CI status failure", () => {
|
|
222
|
+
const d = applyRule(makePr({ checksStatus: "failure" }), makeReview(), { requireCi: false });
|
|
223
|
+
assert.equal(d.ok, true, "no CI requirement -> CI failure must not block");
|
|
224
|
+
});
|
|
225
|
+
test("v0.10.x: applyRule requireReviewerApprove:false ignores BLOCKER verdict", () => {
|
|
226
|
+
const d = applyRule(makePr(), makeReview({ verdict: "BLOCKER", blockers: ["x"] }), { requireReviewerApprove: false });
|
|
227
|
+
assert.equal(d.ok, true);
|
|
228
|
+
});
|
|
229
|
+
test("v0.10.x: applyRule treats maxLoc=0 as 'no cap' (does not block)", () => {
|
|
230
|
+
const d = applyRule(makePr({ additions: 9999, deletions: 9999 }), makeReview(), { maxLoc: 0 });
|
|
231
|
+
assert.equal(d.ok, true, "maxLoc=0 should mean no LoC cap");
|
|
232
|
+
assert.ok(!d.violations.some((v) => v.includes("exceeds cap")));
|
|
233
|
+
});
|
|
234
|
+
test("v0.10.x: applyRule maxLoc undefined also means no cap", () => {
|
|
235
|
+
const d = applyRule(makePr({ additions: 9999, deletions: 9999 }), makeReview(), {});
|
|
236
|
+
assert.ok(!d.violations.some((v) => v.includes("exceeds cap")));
|
|
237
|
+
});
|
|
238
|
+
test("v0.10.x: applyRule blockOnPaths stops at the first matching glob (no accumulation)", () => {
|
|
239
|
+
const pr = makePr({
|
|
240
|
+
files: [
|
|
241
|
+
{ path: "backend/settings.py", additions: 1, deletions: 0 },
|
|
242
|
+
{ path: ".github/workflows/ci.yml", additions: 1, deletions: 0 },
|
|
243
|
+
{ path: "infra/terraform/main.tf", additions: 1, deletions: 0 },
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
const d = applyRule(pr, makeReview(), {
|
|
247
|
+
blockOnPaths: ["**/settings.py", "**/ci.yml", "infra/**"],
|
|
248
|
+
});
|
|
249
|
+
assert.equal(d.ok, false);
|
|
250
|
+
// Only the first matching glob's violation should be recorded; the loop
|
|
251
|
+
// breaks after the first hit.
|
|
252
|
+
const blockHits = d.violations.filter((v) => v.startsWith("block_on_paths:"));
|
|
253
|
+
assert.equal(blockHits.length, 1, "blockOnPaths must short-circuit on first hit");
|
|
254
|
+
assert.match(blockHits[0], /settings\.py/);
|
|
255
|
+
});
|
|
256
|
+
test("v0.10.x: applyRule blockOnMigrations only counts migrations with additions > 0", () => {
|
|
257
|
+
// Removing a migration file (additions:0) is allowed; only newly-added
|
|
258
|
+
// migrations trip the rule.
|
|
259
|
+
const pr = makePr({
|
|
260
|
+
files: [{ path: "backend/deals/migrations/0042_old.py", additions: 0, deletions: 30 }],
|
|
261
|
+
});
|
|
262
|
+
const d = applyRule(pr, makeReview(), { blockOnMigrations: true });
|
|
263
|
+
assert.ok(!d.violations.some((v) => v.includes("migration")), "deletion-only migration shouldn't block");
|
|
264
|
+
});
|
|
265
|
+
test("v0.10.x: applyRule reports REQUEST_CHANGES verdict separately from BLOCKER", () => {
|
|
266
|
+
const d = applyRule(makePr(), makeReview({ verdict: "REQUEST_CHANGES", suggestions: ["rename foo"] }), { requireReviewerApprove: true });
|
|
267
|
+
assert.equal(d.ok, false);
|
|
268
|
+
assert.ok(d.violations.some((v) => v.includes("REQUEST_CHANGES")));
|
|
269
|
+
});
|
|
270
|
+
// ---- tg_classifier.parseSlashCommand corners ----------------------------
|
|
271
|
+
test("v0.10.x: parseSlashCommand tolerates leading + trailing whitespace", () => {
|
|
272
|
+
assert.deepEqual(parseSlashCommand(" /now "), { cmd: "now" });
|
|
273
|
+
assert.deepEqual(parseSlashCommand("\t/queue\n"), { cmd: "queue" });
|
|
274
|
+
});
|
|
275
|
+
test("v0.10.x: parseSlashCommand is case-insensitive on the command head", () => {
|
|
276
|
+
assert.deepEqual(parseSlashCommand("/NOW"), { cmd: "now" });
|
|
277
|
+
assert.deepEqual(parseSlashCommand("/Pause"), { cmd: "pause" });
|
|
278
|
+
assert.deepEqual(parseSlashCommand("/MORNING"), { cmd: "morning" });
|
|
279
|
+
});
|
|
280
|
+
test("v0.10.x: parseSlashCommand returns null for unknown slash commands", () => {
|
|
281
|
+
assert.equal(parseSlashCommand("/foo"), null);
|
|
282
|
+
assert.equal(parseSlashCommand("/help"), null);
|
|
283
|
+
assert.equal(parseSlashCommand("/respondx ab12 hi"), null);
|
|
284
|
+
});
|
|
285
|
+
test("v0.10.x: parseSlashCommand /respond requires both shortId and text", () => {
|
|
286
|
+
assert.equal(parseSlashCommand("/respond"), null);
|
|
287
|
+
assert.equal(parseSlashCommand("/respond ab12cd34"), null, "shortId without text");
|
|
288
|
+
});
|
|
289
|
+
test("v0.10.x: parseSlashCommand /respond preserves dashed shortId", () => {
|
|
290
|
+
const got = parseSlashCommand("/respond ab12-cd34-ef56 yes do it");
|
|
291
|
+
assert.deepEqual(got, { cmd: "respond", shortId: "ab12-cd34-ef56", text: "yes do it" });
|
|
292
|
+
});
|
|
293
|
+
test("v0.10.x: parseSlashCommand /respond joins multi-word text with single spaces", () => {
|
|
294
|
+
const got = parseSlashCommand("/respond xyz the quick brown fox");
|
|
295
|
+
assert.deepEqual(got, { cmd: "respond", shortId: "xyz", text: "the quick brown fox" });
|
|
296
|
+
});
|
|
297
|
+
test("v0.10.x: parseSlashCommand strips @botname suffix even with mixed case", () => {
|
|
298
|
+
assert.deepEqual(parseSlashCommand("/Now@OrqlaudeBot"), { cmd: "now" });
|
|
299
|
+
assert.deepEqual(parseSlashCommand("/budget@somebot extra args"), { cmd: "budget" });
|
|
300
|
+
});
|
|
301
|
+
//# sourceMappingURL=v010_helpers.test.js.map
|