-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlifecycle.test.ts
More file actions
160 lines (152 loc) · 7.1 KB
/
Copy pathlifecycle.test.ts
File metadata and controls
160 lines (152 loc) · 7.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setImmediate } from "node:timers/promises";
import type { Plugin } from "@opencode/plugin";
import { abortable, cleanup, heartbeat, touchOwner, type OwnerClient } from "../src/lifecycle.js";
import github from "../src/plugins/github.js";
import scheduler from "../src/plugins/scheduler.js";
import { acquire } from "../src/state.js";
import { runtimeBridge } from "../src/bridge.js";
test("cleanup settles work and releases ownership even after disposal failures", async () => {
const steps: string[] = [];
const failure = new Error("SDK scope was evicted");
await assert.rejects(cleanup(
async () => { steps.push("settled"); },
() => { steps.push("dispose"); throw failure; },
() => { steps.push("bridge removed"); },
() => { steps.push("lock released"); },
), (error: AggregateError) => error.errors[0] === failure);
assert.deepEqual(steps, ["settled", "dispose", "bridge removed", "lock released"]);
});
for (const kind of ["github", "scheduler"] as const) {
test(`${kind} plugin releases its actual lock when RPC disposal rejects`, async () => {
const directory = await realpath(await mkdtemp(join(tmpdir(), "oc2-lifecycle-")));
const oldState = process.env.XDG_STATE_HOME;
process.env.XDG_STATE_HOME = directory;
const tokenName = "OC2_LIFECYCLE_TEST_TOKEN";
process.env[tokenName] = "test-token";
const rpc = Object.assign(() => ({ scan: async () => ({}) }), {
register: async () => ({ dispose: async () => { throw new Error("evicted registration"); }, events: { emit: async () => {} } }),
});
const options = kind === "github" ? {
tokenEnv: tokenName, ownerDirectory: directory, stateDirectory: directory,
autoMerge: { enabled: false },
repositories: [{ repo: "owner/repo", directory, baseBranch: "main", allowedAuthors: ["alice"], checks: [] }],
routes: { "@bot": { model: { providerID: "test", id: "model" } } },
} : { ownerDirectory: directory, stateDirectory: directory, jobs: [{ id: "scan", everySeconds: 60 }] };
try {
const plugin = kind === "github" ? github : scheduler;
const stop = await plugin.setup({ location: { directory }, options, rpc, session: {} } as unknown as Plugin.Context);
assert.ok(stop);
await assert.rejects(async () => stop(), /Automation cleanup failed/);
const release = await acquire(directory, kind, () => {});
await release();
assert.equal(runtimeBridge(directory), undefined);
} finally {
if (oldState === undefined) delete process.env.XDG_STATE_HOME; else process.env.XDG_STATE_HOME = oldState;
delete process.env[tokenName];
await rm(directory, { recursive: true, force: true });
}
});
}
test("owner heartbeat only touches the matching service process and owner directory", async () => {
const calls: string[] = [];
let pid = process.pid + 1;
const client: OwnerClient = {
server: { info: async () => ({ pid }) },
session: {
create: async input => { calls.push(input.location.directory); return input; },
update: async () => { calls.push("activity"); },
},
};
const signal = new AbortController().signal;
assert.equal(await touchOwner("/owner", signal, async () => undefined), false);
assert.equal(await touchOwner("/owner", signal, async () => client), false);
assert.deepEqual(calls, []);
pid = process.pid;
assert.equal(await touchOwner("/owner", signal, async () => client), true);
assert.deepEqual(calls, ["/owner", "activity"]);
});
test("keepalive emits durable owner activity, reuses one session, and never invokes a model", async () => {
let session: Awaited<ReturnType<OwnerClient["session"]["create"]>> | undefined;
let created = 0, renamed = 0, clock = 0, expiresAt = 60;
const client: OwnerClient = {
server: { info: async () => ({ pid: process.pid }) },
session: {
create: async input => { if (!session) { session = input; created++; } assert.equal(input.id, session.id); return session; },
// OpenCode LocationActivity refreshes only on durable SessionEvent events.
update: async ({ sessionID }) => { assert.equal(sessionID, session!.id); expiresAt = clock + 60; renamed++; },
},
};
for (clock = 0; clock <= 180; clock += 10) {
assert.ok(clock < expiresAt);
await touchOwner("/owner", new AbortController().signal, async () => client);
}
assert.equal(created, 1);
assert.equal(renamed, 19);
session!.location.directory = "/unrelated";
await assert.rejects(touchOwner("/owner", new AbortController().signal, async () => client), /identity mismatch/);
assert.equal(renamed, 19);
});
test("heartbeat does not overlap requests and aborts pending work on cleanup", async t => {
t.mock.timers.enable({ apis: ["setInterval"] });
let calls = 0;
let pendingSignal: AbortSignal | undefined;
const errors: unknown[] = [];
const stop = heartbeat(signal => {
calls++;
pendingSignal = signal;
return new Promise((_, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }));
}, error => errors.push(error), 30_000);
t.mock.timers.tick(30_000);
await setImmediate();
t.mock.timers.tick(60_000);
await setImmediate();
assert.equal(calls, 1);
await stop();
assert.equal(pendingSignal?.aborted, true);
assert.deepEqual(errors, []);
t.mock.timers.tick(60_000);
await setImmediate();
assert.equal(calls, 1);
});
test("heartbeat retries after a transient failure", async t => {
t.mock.timers.enable({ apis: ["setInterval"] });
let calls = 0;
const errors: unknown[] = [];
const stop = heartbeat(async () => { if (++calls === 1) throw new Error("connection lost"); }, error => errors.push(error), 30_000);
try {
// The initial touch runs immediately, before the first interval.
await setImmediate();
t.mock.timers.tick(30_000);
await setImmediate();
assert.equal(calls, 2);
assert.equal(errors.length, 1);
} finally { await stop(); }
});
test("abortable exits even when an SDK ignores its signal and observes late rejection", async () => {
const controller = new AbortController();
let reject!: (reason: Error) => void;
const pending = abortable(() => new Promise<void>((_, fail) => { reject = fail; }), controller.signal);
await setImmediate();
controller.abort(new Error("owner disposed"));
await assert.rejects(pending, /owner disposed/);
reject(new Error("late SDK failure"));
await setImmediate();
});
test("replacement waits for the real ownership lock without stealing it", async () => {
const directory = await realpath(await mkdtemp(join(tmpdir(), "oc2-handoff-")));
try {
const release = await acquire(directory, "github", () => {});
let acquired = false;
const replacement = acquire(directory, "github", () => {}, true).then(stop => { acquired = true; return stop; });
await setImmediate();
assert.equal(acquired, false);
await release();
await (await replacement)();
assert.equal(acquired, true);
} finally { await rm(directory, { recursive: true, force: true }); }
});