Skip to content

Commit 1cbf0b2

Browse files
kasin-itwoywro
andauthored
Add skills and fix race condition (#27)
* chore: trigger CI * feat: add skills (#24) * feat: add skills * feat: remove comments * feat: improve prompt * Fix/webhook polling race conditions (#26) * fix: resolve webhook/polling race conditions with timestamped claims * refactor: dispatch/webhook/polling handlers for readability --------- Co-authored-by: woywro <woywro@gmail.com>
1 parent 4e5b7e3 commit 1cbf0b2

14 files changed

Lines changed: 714 additions & 187 deletions

scripts/test-sandbox-skills.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Debug script: provisions a bare sandbox, installs skills globally, and dumps the results.
3+
* Uses Vercel OIDC for sandbox auth — no repo needed.
4+
*
5+
* Usage:
6+
* npx tsx scripts/test-sandbox-skills.ts
7+
*/
8+
9+
import { Sandbox } from "@vercel/sandbox";
10+
11+
const INJECTED_SKILLS = [
12+
{ repo: "https://github.com/obra/superpowers", skill: "using-superpowers" },
13+
{ repo: "https://github.com/obra/superpowers", skill: "requesting-code-review" },
14+
{ repo: "https://github.com/anthropics/skills", skill: "frontend-design" },
15+
];
16+
17+
async function main() {
18+
console.log("\n=== Provisioning sandbox ===\n");
19+
20+
const sandbox = await Sandbox.create({
21+
runtime: "node24",
22+
timeout: 300_000,
23+
});
24+
25+
console.log("Sandbox created.\n");
26+
27+
await sandbox.runCommand("bash", ["-c", "git init && git commit --allow-empty -m 'init'"]);
28+
29+
console.log("=== Installing Claude Code ===");
30+
const installCC = await sandbox.runCommand("npm", [
31+
"install",
32+
"-g",
33+
"@anthropic-ai/claude-code",
34+
]);
35+
console.log("stdout:", (await installCC.stdout()).slice(-200));
36+
console.log("stderr:", (await installCC.stderr()).slice(-200));
37+
38+
for (const { repo, skill } of INJECTED_SKILLS) {
39+
console.log(`\n=== Installing skill globally: ${skill} ===`);
40+
const result = await sandbox.runCommand("npx", [
41+
"-y", "skills", "add", repo, "--skill", skill, "--yes", "-g",
42+
]);
43+
console.log("stdout:", (await result.stdout()).slice(-300) || "(empty)");
44+
console.log("stderr:", (await result.stderr()).slice(-300) || "(empty)");
45+
}
46+
47+
console.log("\n=== .claude/skills/ (project) ===");
48+
const projectSkills = await sandbox.runCommand("bash", [
49+
"-c",
50+
"ls -laR .claude/skills/ 2>/dev/null || echo '(directory does not exist)'",
51+
]);
52+
console.log(await projectSkills.stdout());
53+
54+
console.log("=== skills-lock.json (project) ===");
55+
const lock = await sandbox.runCommand("bash", [
56+
"-c",
57+
"cat skills-lock.json 2>/dev/null || echo '(file does not exist)'",
58+
]);
59+
console.log(await lock.stdout());
60+
61+
console.log("=== ~/.claude/skills/ (global) ===");
62+
const globalSkills = await sandbox.runCommand("bash", [
63+
"-c",
64+
"ls -laR ~/.claude/skills/ 2>/dev/null || echo '(directory does not exist)'",
65+
]);
66+
console.log(await globalSkills.stdout());
67+
68+
console.log("=== Find all SKILL.md files (everywhere) ===");
69+
const skillFiles = await sandbox.runCommand("bash", [
70+
"-c",
71+
"find / -name 'SKILL.md' -not -path '*/node_modules/*' 2>/dev/null || echo '(none found)'",
72+
]);
73+
console.log(await skillFiles.stdout());
74+
75+
console.log("\n=== Stopping sandbox ===");
76+
await sandbox.stop();
77+
console.log("Done.");
78+
}
79+
80+
main().catch((err) => {
81+
console.error("Fatal:", err);
82+
process.exit(1);
83+
});

src/lib/cancel-run.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import type { RunRegistryAdapter } from "../adapters/run-registry/types.js";
3+
4+
const mockGetRun = vi.fn();
5+
vi.mock("workflow/api", () => ({
6+
getRun: (...args: any[]) => mockGetRun(...args),
7+
}));
8+
9+
function makeRegistry(overrides: Partial<RunRegistryAdapter> = {}): RunRegistryAdapter {
10+
return {
11+
claim: vi.fn(),
12+
register: vi.fn(),
13+
getRunId: vi.fn(),
14+
unregister: overrides.unregister ?? vi.fn().mockResolvedValue(undefined),
15+
listAll: vi.fn(),
16+
};
17+
}
18+
19+
describe("cancelRun", () => {
20+
beforeEach(() => vi.clearAllMocks());
21+
22+
it("cancels the run and unregisters", async () => {
23+
const mockCancel = vi.fn().mockResolvedValue(undefined);
24+
mockGetRun.mockReturnValue({ cancel: mockCancel });
25+
const registry = makeRegistry();
26+
27+
const { cancelRun } = await import("./cancel-run.js");
28+
const result = await cancelRun("PROJ-1", "run_abc", registry);
29+
30+
expect(result).toBe(true);
31+
expect(mockGetRun).toHaveBeenCalledWith("run_abc");
32+
expect(mockCancel).toHaveBeenCalled();
33+
expect(registry.unregister).toHaveBeenCalledWith("PROJ-1");
34+
});
35+
36+
it("returns false and still unregisters when cancel throws", async () => {
37+
mockGetRun.mockReturnValue({
38+
cancel: vi.fn().mockRejectedValue(new Error("run gone")),
39+
});
40+
const registry = makeRegistry();
41+
42+
const { cancelRun } = await import("./cancel-run.js");
43+
const result = await cancelRun("PROJ-1", "run_abc", registry);
44+
45+
expect(result).toBe(false);
46+
expect(registry.unregister).toHaveBeenCalledWith("PROJ-1");
47+
});
48+
49+
it("is idempotent — second call on same ticket returns false without throwing", async () => {
50+
mockGetRun.mockReturnValue({
51+
cancel: vi.fn().mockRejectedValue(new Error("already cancelled")),
52+
});
53+
const unregister = vi.fn().mockResolvedValue(undefined);
54+
const registry = makeRegistry({ unregister });
55+
56+
const { cancelRun } = await import("./cancel-run.js");
57+
await cancelRun("PROJ-1", "run_abc", registry);
58+
const result = await cancelRun("PROJ-1", "run_abc", registry);
59+
60+
expect(result).toBe(false);
61+
expect(unregister).toHaveBeenCalledTimes(2);
62+
});
63+
});

src/lib/cancel-run.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { getRun } from "workflow/api";
2+
import { logger } from "./logger.js";
3+
import type { RunRegistryAdapter } from "../adapters/run-registry/types.js";
4+
5+
/**
6+
* Cancel a workflow run and unregister it from the registry.
7+
* Idempotent: safe to call multiple times for the same ticket.
8+
* Returns true if cancel succeeded, false if it errored (still unregisters).
9+
*/
10+
export async function cancelRun(
11+
ticketKey: string,
12+
runId: string,
13+
runRegistry: RunRegistryAdapter,
14+
): Promise<boolean> {
15+
let cancelled = false;
16+
try {
17+
const run = getRun(runId);
18+
await run.cancel();
19+
cancelled = true;
20+
} catch (err) {
21+
logger.warn(
22+
{ ticketKey, runId, error: (err as Error).message },
23+
"cancel_run_error",
24+
);
25+
}
26+
27+
await runRegistry.unregister(ticketKey).catch(() => {});
28+
return cancelled;
29+
}

src/lib/dispatch.test.ts

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
22
import type { Adapters } from "./adapters.js";
33
import type { TicketContent } from "../adapters/issue-tracker/types.js";
44

5-
65
const mockStart = vi.fn();
6+
const mockGetRun = vi.fn();
77
vi.mock("workflow/api", () => ({
88
start: (...args: any[]) => mockStart(...args),
9+
getRun: (...args: any[]) => mockGetRun(...args),
910
}));
1011

1112
vi.mock("../workflows/implementation.js", () => ({
@@ -23,7 +24,6 @@ vi.mock("@vercel/sandbox", () => ({
2324
},
2425
}));
2526

26-
2727
function makeTicket(overrides: Partial<TicketContent> = {}): TicketContent {
2828
return {
2929
id: "ticket-001",
@@ -38,16 +38,22 @@ function makeTicket(overrides: Partial<TicketContent> = {}): TicketContent {
3838
};
3939
}
4040

41-
function makeAdapters(overrides: Partial<{
42-
claim: ReturnType<typeof vi.fn>;
43-
register: ReturnType<typeof vi.fn>;
44-
unregister: ReturnType<typeof vi.fn>;
45-
fetchTicket: ReturnType<typeof vi.fn>;
46-
findPR: ReturnType<typeof vi.fn>;
47-
}>= {}): Adapters {
41+
function makeAdapters(
42+
overrides: Partial<{
43+
claim: ReturnType<typeof vi.fn>;
44+
register: ReturnType<typeof vi.fn>;
45+
unregister: ReturnType<typeof vi.fn>;
46+
getRunId: ReturnType<typeof vi.fn>;
47+
fetchTicket: ReturnType<typeof vi.fn>;
48+
findPR: ReturnType<typeof vi.fn>;
49+
}> = {},
50+
): Adapters {
51+
let claimedValue: string | undefined;
52+
4853
return {
4954
issueTracker: {
50-
fetchTicket: overrides.fetchTicket ?? vi.fn().mockResolvedValue(makeTicket()),
55+
fetchTicket:
56+
overrides.fetchTicket ?? vi.fn().mockResolvedValue(makeTicket()),
5157
moveTicket: vi.fn(),
5258
postComment: vi.fn(),
5359
searchTickets: vi.fn(),
@@ -64,16 +70,22 @@ function makeAdapters(overrides: Partial<{
6470
notify: vi.fn(),
6571
},
6672
runRegistry: {
67-
claim: overrides.claim ?? vi.fn().mockResolvedValue(true),
73+
claim:
74+
overrides.claim ??
75+
vi.fn().mockImplementation(async (_key: string, value: string) => {
76+
claimedValue = value;
77+
return true;
78+
}),
6879
register: overrides.register ?? vi.fn().mockResolvedValue(undefined),
6980
unregister: overrides.unregister ?? vi.fn().mockResolvedValue(undefined),
70-
getRunId: vi.fn(),
81+
getRunId:
82+
overrides.getRunId ??
83+
vi.fn().mockImplementation(async () => claimedValue),
7184
listAll: vi.fn(),
7285
},
7386
};
7487
}
7588

76-
7789
describe("dispatchTicket", () => {
7890
beforeEach(() => {
7991
vi.clearAllMocks();
@@ -90,24 +102,44 @@ describe("dispatchTicket", () => {
90102
const result = await dispatchTicket("PROJ-42", adapters, 5);
91103

92104
expect(result).toEqual({ started: true, runId: "run_123" });
93-
expect(adapters.runRegistry.claim).toHaveBeenCalledWith("PROJ-42", "claiming");
105+
expect(adapters.runRegistry.claim).toHaveBeenCalledWith(
106+
"PROJ-42",
107+
expect.stringMatching(/^claiming:\d+$/),
108+
);
94109
expect(adapters.issueTracker.fetchTicket).toHaveBeenCalledWith("PROJ-42");
95110
expect(adapters.vcs.findPR).toHaveBeenCalledWith("blazebot/proj-42");
96-
expect(mockStart).toHaveBeenCalledWith("implementationWorkflow_sentinel", ["ticket-001"]);
97-
expect(adapters.runRegistry.register).toHaveBeenCalledWith("PROJ-42", "run_123");
111+
expect(mockStart).toHaveBeenCalledWith("implementationWorkflow_sentinel", [
112+
"ticket-001",
113+
]);
114+
expect(adapters.runRegistry.register).toHaveBeenCalledWith(
115+
"PROJ-42",
116+
"run_123",
117+
);
98118
});
99119

100120
it("dispatches review-fix workflow when PR exists", async () => {
101121
const adapters = makeAdapters({
102-
findPR: vi.fn().mockResolvedValue({ id: 7, url: "https://github.com/pr/7", branch: "blazebot/proj-42" }),
122+
findPR: vi
123+
.fn()
124+
.mockResolvedValue({
125+
id: 7,
126+
url: "https://github.com/pr/7",
127+
branch: "blazebot/proj-42",
128+
}),
103129
});
104130
const { dispatchTicket } = await import("./dispatch.js");
105131

106132
const result = await dispatchTicket("PROJ-42", adapters, 5);
107133

108134
expect(result).toEqual({ started: true, runId: "run_123" });
109-
expect(mockStart).toHaveBeenCalledWith("reviewFixWorkflow_sentinel", ["ticket-001", "blazebot/proj-42"]);
110-
expect(adapters.runRegistry.register).toHaveBeenCalledWith("PROJ-42", "run_123");
135+
expect(mockStart).toHaveBeenCalledWith("reviewFixWorkflow_sentinel", [
136+
"ticket-001",
137+
"blazebot/proj-42",
138+
]);
139+
expect(adapters.runRegistry.register).toHaveBeenCalledWith(
140+
"PROJ-42",
141+
"run_123",
142+
);
111143
});
112144

113145
it("returns already_claimed when claim fails", async () => {
@@ -143,6 +175,25 @@ describe("dispatchTicket", () => {
143175
expect(mockStart).not.toHaveBeenCalled();
144176
});
145177

178+
it("aborts workflow if claim was removed during dispatch", async () => {
179+
const mockCancel = vi.fn().mockResolvedValue(undefined);
180+
mockGetRun.mockReturnValue({ cancel: mockCancel });
181+
182+
// getRunId returns null — claim was removed by a cancel while workflow was starting
183+
const adapters = makeAdapters({
184+
getRunId: vi.fn().mockResolvedValue(null),
185+
});
186+
const { dispatchTicket } = await import("./dispatch.js");
187+
188+
const result = await dispatchTicket("PROJ-42", adapters, 5);
189+
190+
expect(result).toEqual({ started: false, reason: "already_claimed" });
191+
expect(mockStart).toHaveBeenCalled();
192+
expect(mockGetRun).toHaveBeenCalledWith("run_123");
193+
expect(mockCancel).toHaveBeenCalled();
194+
expect(adapters.runRegistry.register).not.toHaveBeenCalled();
195+
});
196+
146197
it("unregisters claim and returns error on dispatch failure", async () => {
147198
const unregister = vi.fn().mockResolvedValue(undefined);
148199
const adapters = makeAdapters({

0 commit comments

Comments
 (0)