@atolis-hq/wake 0.2.13 → 0.2.15
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/dist/src/adapters/http/ui-server.js +16 -0
- package/dist/src/cli/sandbox-command.js +6 -0
- package/dist/src/cli/self-update-command.js +3 -0
- package/dist/src/cli/stop-command.js +4 -0
- package/dist/src/core/active-run-recovery.js +39 -0
- package/dist/src/core/stale-run-reconciler.js +17 -7
- package/dist/src/core/tick-runner.js +11 -1
- package/dist/src/lib/lock.js +67 -4
- package/dist/src/main.js +8 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -96,6 +96,22 @@ async function handleRequest(req, res, options, now) {
|
|
|
96
96
|
sendJson(res, 202, request);
|
|
97
97
|
return;
|
|
98
98
|
}
|
|
99
|
+
if (req.method === 'POST' &&
|
|
100
|
+
resource === 'runners' &&
|
|
101
|
+
segments.length === 3 &&
|
|
102
|
+
segments[2] === 'unpause') {
|
|
103
|
+
const runnerName = segments[1];
|
|
104
|
+
const ledger = await stateStore.readLedger();
|
|
105
|
+
const existingRunners = ledger?.runners ?? {};
|
|
106
|
+
if (existingRunners[runnerName] !== undefined) {
|
|
107
|
+
await stateStore.writeLedger({
|
|
108
|
+
schemaVersion: 1,
|
|
109
|
+
runners: { ...existingRunners, [runnerName]: { failureCount: 0 } },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
sendJson(res, 200, { runnerName, unpaused: true });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
99
115
|
if (req.method !== 'GET') {
|
|
100
116
|
sendJson(res, 405, { error: `method not allowed for ${url.pathname}` });
|
|
101
117
|
return;
|
|
@@ -148,6 +148,9 @@ export async function runSandboxCommand(input) {
|
|
|
148
148
|
await runStopCommand({
|
|
149
149
|
args: input.args.slice(1),
|
|
150
150
|
stateStore: input.stateStore,
|
|
151
|
+
...(input.recoverActiveRuns === undefined
|
|
152
|
+
? {}
|
|
153
|
+
: { recoverActiveRuns: input.recoverActiveRuns }),
|
|
151
154
|
docker: input.docker,
|
|
152
155
|
containerName: input.config.sandbox.containerName,
|
|
153
156
|
sleep: input.sleep,
|
|
@@ -174,6 +177,9 @@ export async function runSandboxCommand(input) {
|
|
|
174
177
|
imageRepository: input.config.sandbox.imageRepository,
|
|
175
178
|
containerName: input.config.sandbox.containerName,
|
|
176
179
|
stateStore: input.stateStore,
|
|
180
|
+
...(input.recoverActiveRuns === undefined
|
|
181
|
+
? {}
|
|
182
|
+
: { recoverActiveRuns: input.recoverActiveRuns }),
|
|
177
183
|
docker: input.docker,
|
|
178
184
|
git: input.selfUpdate.git,
|
|
179
185
|
issueReporter: input.selfUpdate.issueReporter,
|
|
@@ -72,6 +72,9 @@ export async function runSelfUpdateCommand(input) {
|
|
|
72
72
|
}
|
|
73
73
|
await waitForActiveRuns({
|
|
74
74
|
listRunRecords: input.stateStore.listRunRecords,
|
|
75
|
+
...(input.recoverActiveRuns === undefined
|
|
76
|
+
? {}
|
|
77
|
+
: { recoverActiveRuns: input.recoverActiveRuns }),
|
|
75
78
|
sleep: input.sleep,
|
|
76
79
|
logger: input.logger,
|
|
77
80
|
});
|
|
@@ -4,6 +4,7 @@ export async function waitForActiveRuns(input) {
|
|
|
4
4
|
const pollIntervalMs = input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
5
5
|
const startedAt = Date.now();
|
|
6
6
|
for (;;) {
|
|
7
|
+
await input.recoverActiveRuns?.();
|
|
7
8
|
const records = await input.listRunRecords();
|
|
8
9
|
const activeRuns = records.filter((record) => record.status === 'running');
|
|
9
10
|
if (activeRuns.length === 0) {
|
|
@@ -32,6 +33,9 @@ function readNumberFlag(name, args) {
|
|
|
32
33
|
export async function runStopCommand(input) {
|
|
33
34
|
await waitForActiveRuns({
|
|
34
35
|
listRunRecords: input.stateStore.listRunRecords,
|
|
36
|
+
...(input.recoverActiveRuns === undefined
|
|
37
|
+
? {}
|
|
38
|
+
: { recoverActiveRuns: input.recoverActiveRuns }),
|
|
35
39
|
sleep: input.sleep,
|
|
36
40
|
pollIntervalMs: readNumberFlag('--poll-interval-ms', input.args),
|
|
37
41
|
timeoutMs: readNumberFlag('--timeout-ms', input.args),
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileLockStatus } from '../lib/lock.js';
|
|
2
|
+
import { maxConfiguredRunnerTimeoutMs } from '../domain/runner-routing.js';
|
|
3
|
+
import { createOutbox } from './outbox.js';
|
|
4
|
+
import { createProjectionUpdater } from './projection-updater.js';
|
|
5
|
+
import { createStaleRunReconciler } from './stale-run-reconciler.js';
|
|
6
|
+
export function createActiveRunRecovery(deps) {
|
|
7
|
+
const projectionUpdater = createProjectionUpdater({
|
|
8
|
+
stateStore: deps.stateStore,
|
|
9
|
+
resourceIndex: deps.resourceIndex,
|
|
10
|
+
config: deps.config,
|
|
11
|
+
});
|
|
12
|
+
const { deliverOutboundEvent } = createOutbox({
|
|
13
|
+
clock: deps.clock,
|
|
14
|
+
stateStore: deps.stateStore,
|
|
15
|
+
projectionUpdater,
|
|
16
|
+
});
|
|
17
|
+
async function isRunningRecordActive(record) {
|
|
18
|
+
const lock = await readFileLockStatus(deps.stateStore.paths.runnerLockFile, {
|
|
19
|
+
expectedCommandIncludes: process.argv[1] === undefined ? [] : [process.argv[1]],
|
|
20
|
+
});
|
|
21
|
+
if (!lock.active || lock.metadata === undefined) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return Date.parse(lock.metadata.acquiredAt) <= Date.parse(record.startedAt);
|
|
25
|
+
}
|
|
26
|
+
const { reconcileStaleRunningRecords } = createStaleRunReconciler({
|
|
27
|
+
config: deps.config,
|
|
28
|
+
stateStore: deps.stateStore,
|
|
29
|
+
projectionUpdater,
|
|
30
|
+
runnerTimeoutMs: () => maxConfiguredRunnerTimeoutMs(deps.config),
|
|
31
|
+
isRunningRecordActive,
|
|
32
|
+
deliverOutboundEvent,
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
recoverActiveRuns() {
|
|
36
|
+
return reconcileStaleRunningRecords(deps.clock.now());
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -8,21 +8,30 @@ import { createEventEnvelope } from '../lib/event-log.js';
|
|
|
8
8
|
// `deliverOutboundEvent` is injected rather than imported so this module does
|
|
9
9
|
// not depend on the outbox module directly.
|
|
10
10
|
export function createStaleRunReconciler(deps) {
|
|
11
|
-
function
|
|
11
|
+
async function staleReason(record, now) {
|
|
12
12
|
if (record.status !== 'running') {
|
|
13
|
-
return
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
if (deps.isRunningRecordActive !== undefined && !(await deps.isRunningRecordActive(record))) {
|
|
16
|
+
return 'runner-lock-not-active';
|
|
14
17
|
}
|
|
15
18
|
const startedAtMs = Date.parse(record.startedAt);
|
|
16
19
|
if (!Number.isFinite(startedAtMs)) {
|
|
17
|
-
return
|
|
20
|
+
return 'timeout';
|
|
18
21
|
}
|
|
19
|
-
return now.getTime() - startedAtMs >= deps.runnerTimeoutMs();
|
|
22
|
+
return now.getTime() - startedAtMs >= deps.runnerTimeoutMs() ? 'timeout' : null;
|
|
20
23
|
}
|
|
21
24
|
async function reconcileStaleRunningRecords(now) {
|
|
22
25
|
const finishedAt = now.toISOString();
|
|
23
26
|
const runRecords = await deps.stateStore.listRunRecords();
|
|
24
|
-
const staleRecords =
|
|
25
|
-
for (const record of
|
|
27
|
+
const staleRecords = [];
|
|
28
|
+
for (const record of runRecords) {
|
|
29
|
+
const reason = await staleReason(record, now);
|
|
30
|
+
if (reason !== null) {
|
|
31
|
+
staleRecords.push({ record, reason });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const { record, reason } of staleRecords) {
|
|
26
35
|
// Run records carry the work item they belong to, so this is a direct
|
|
27
36
|
// O(1) read — no scan, no index, no source ambiguity. The record's
|
|
28
37
|
// repo/issueNumber are representation content and take no part in it.
|
|
@@ -57,6 +66,7 @@ export function createStaleRunReconciler(deps) {
|
|
|
57
66
|
metadata: {
|
|
58
67
|
...record.metadata,
|
|
59
68
|
reconciledBy: 'stale-running-record',
|
|
69
|
+
staleReason: reason,
|
|
60
70
|
timeoutMs: deps.runnerTimeoutMs(),
|
|
61
71
|
},
|
|
62
72
|
});
|
|
@@ -79,7 +89,7 @@ export function createStaleRunReconciler(deps) {
|
|
|
79
89
|
action: record.action,
|
|
80
90
|
sentinel: 'FAILED',
|
|
81
91
|
runId: record.runId,
|
|
82
|
-
reason: 'runner:stale-timeout',
|
|
92
|
+
reason: reason === 'timeout' ? 'runner:stale-timeout' : 'runner:orphaned-process',
|
|
83
93
|
...(record.routing === undefined ? {} : { routing: record.routing }),
|
|
84
94
|
},
|
|
85
95
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createLifecycleService } from './lifecycle-service.js';
|
|
2
2
|
import { createPolicyEngine } from './policy-engine.js';
|
|
3
3
|
import { createProjectionUpdater } from './projection-updater.js';
|
|
4
|
-
import { acquireFileLock } from '../lib/lock.js';
|
|
4
|
+
import { acquireFileLock, readFileLockStatus } from '../lib/lock.js';
|
|
5
5
|
import { CORRELATION_REGISTERED_EVENT, parseRunnerArtifacts, parseRunnerResult, } from '../domain/schema.js';
|
|
6
6
|
import { maxConfiguredRunnerTimeoutMs, resolveRunnerRouting } from '../domain/runner-routing.js';
|
|
7
7
|
import { awaitingApprovalRunnerSentinel, stageLabelForStage } from '../domain/stages.js';
|
|
@@ -58,6 +58,7 @@ export function createTickRunner(deps) {
|
|
|
58
58
|
stateStore: deps.stateStore,
|
|
59
59
|
projectionUpdater,
|
|
60
60
|
runnerTimeoutMs,
|
|
61
|
+
isRunningRecordActive,
|
|
61
62
|
deliverOutboundEvent,
|
|
62
63
|
});
|
|
63
64
|
const { cleanupClosedIssueWorkspaces } = createWorkspaceCleanup({
|
|
@@ -203,6 +204,15 @@ export function createTickRunner(deps) {
|
|
|
203
204
|
function runnerTimeoutMs() {
|
|
204
205
|
return maxConfiguredRunnerTimeoutMs(deps.config);
|
|
205
206
|
}
|
|
207
|
+
async function isRunningRecordActive(record) {
|
|
208
|
+
const lock = await readFileLockStatus(deps.stateStore.paths.runnerLockFile, {
|
|
209
|
+
expectedCommandIncludes: process.argv[1] === undefined ? [] : [process.argv[1]],
|
|
210
|
+
});
|
|
211
|
+
if (!lock.active || lock.metadata === undefined) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
return Date.parse(lock.metadata.acquiredAt) <= Date.parse(record.startedAt);
|
|
215
|
+
}
|
|
206
216
|
async function parkConfigDriftedProjections(projections) {
|
|
207
217
|
let parked = false;
|
|
208
218
|
for (const projection of projections) {
|
package/dist/src/lib/lock.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
import { link, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -21,6 +22,7 @@ function parseLockMetadata(raw) {
|
|
|
21
22
|
pid: record.pid,
|
|
22
23
|
acquiredAt: record.acquiredAt,
|
|
23
24
|
...(typeof record.lockId === 'string' ? { lockId: record.lockId } : {}),
|
|
25
|
+
...(typeof record.commandLine === 'string' ? { commandLine: record.commandLine } : {}),
|
|
24
26
|
};
|
|
25
27
|
}
|
|
26
28
|
catch {
|
|
@@ -43,6 +45,28 @@ function isPidAlive(pid) {
|
|
|
43
45
|
return true;
|
|
44
46
|
}
|
|
45
47
|
}
|
|
48
|
+
function readProcessCommandLine(pid) {
|
|
49
|
+
if (pid === process.pid) {
|
|
50
|
+
return process.argv.join(' ');
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return readFileSyncProcessCommandLine(pid);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function readFileSyncProcessCommandLine(pid) {
|
|
60
|
+
const raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
61
|
+
const commandLine = raw.replace(/\0/g, ' ').trim();
|
|
62
|
+
return commandLine.length === 0 ? undefined : commandLine;
|
|
63
|
+
}
|
|
64
|
+
function defaultProcessInspector() {
|
|
65
|
+
return {
|
|
66
|
+
isPidAlive,
|
|
67
|
+
readCommandLine: readProcessCommandLine,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
46
70
|
async function readLockMetadata(path) {
|
|
47
71
|
try {
|
|
48
72
|
return parseLockMetadata(await readFile(path, 'utf8'));
|
|
@@ -51,13 +75,48 @@ async function readLockMetadata(path) {
|
|
|
51
75
|
return null;
|
|
52
76
|
}
|
|
53
77
|
}
|
|
54
|
-
async function
|
|
78
|
+
export async function readFileLockStatus(path, options) {
|
|
55
79
|
const metadata = await readLockMetadata(path);
|
|
56
80
|
if (metadata === null) {
|
|
57
|
-
|
|
81
|
+
try {
|
|
82
|
+
await readFile(path, 'utf8');
|
|
83
|
+
return { present: true, active: false, staleReason: 'invalid-metadata' };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { present: false, active: false, staleReason: 'missing' };
|
|
87
|
+
}
|
|
58
88
|
}
|
|
89
|
+
const now = options?.now ?? new Date();
|
|
59
90
|
const ageMs = now.getTime() - Date.parse(metadata.acquiredAt);
|
|
60
|
-
|
|
91
|
+
if (options?.staleAfterMs !== undefined && ageMs >= options.staleAfterMs) {
|
|
92
|
+
return { present: true, active: false, metadata, ageMs, staleReason: 'expired' };
|
|
93
|
+
}
|
|
94
|
+
const inspector = options?.processInspector ?? defaultProcessInspector();
|
|
95
|
+
if (!inspector.isPidAlive(metadata.pid)) {
|
|
96
|
+
return { present: true, active: false, metadata, ageMs, staleReason: 'pid-not-alive' };
|
|
97
|
+
}
|
|
98
|
+
const commandLine = inspector.readCommandLine(metadata.pid);
|
|
99
|
+
if (commandLine !== undefined &&
|
|
100
|
+
((metadata.commandLine !== undefined && commandLine !== metadata.commandLine) ||
|
|
101
|
+
(metadata.commandLine === undefined &&
|
|
102
|
+
options?.expectedCommandIncludes !== undefined &&
|
|
103
|
+
!options.expectedCommandIncludes.every((fragment) => commandLine.includes(fragment))))) {
|
|
104
|
+
return {
|
|
105
|
+
present: true,
|
|
106
|
+
active: false,
|
|
107
|
+
metadata,
|
|
108
|
+
ageMs,
|
|
109
|
+
commandLine,
|
|
110
|
+
staleReason: 'pid-command-mismatch',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
present: true,
|
|
115
|
+
active: true,
|
|
116
|
+
metadata,
|
|
117
|
+
ageMs,
|
|
118
|
+
...(commandLine === undefined ? {} : { commandLine }),
|
|
119
|
+
};
|
|
61
120
|
}
|
|
62
121
|
export async function acquireFileLock(path, options) {
|
|
63
122
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -65,6 +124,7 @@ export async function acquireFileLock(path, options) {
|
|
|
65
124
|
pid: process.pid,
|
|
66
125
|
acquiredAt: (options?.now ?? new Date()).toISOString(),
|
|
67
126
|
lockId: randomUUID(),
|
|
127
|
+
commandLine: process.argv.join(' '),
|
|
68
128
|
};
|
|
69
129
|
async function tryAcquire() {
|
|
70
130
|
const tempPath = join(dirname(path), `.tick-lock-${metadata.lockId}.tmp`);
|
|
@@ -97,7 +157,10 @@ export async function acquireFileLock(path, options) {
|
|
|
97
157
|
catch (error) {
|
|
98
158
|
if (error.code === 'EEXIST') {
|
|
99
159
|
if (options?.staleAfterMs !== undefined &&
|
|
100
|
-
(await
|
|
160
|
+
!(await readFileLockStatus(path, {
|
|
161
|
+
staleAfterMs: options.staleAfterMs,
|
|
162
|
+
now: options.now ?? new Date(),
|
|
163
|
+
})).active) {
|
|
101
164
|
await rm(path, { force: true });
|
|
102
165
|
try {
|
|
103
166
|
return await tryAcquire();
|
package/dist/src/main.js
CHANGED
|
@@ -28,6 +28,7 @@ import { runSandboxSetupCommand } from './cli/sandbox-setup-command.js';
|
|
|
28
28
|
import { collectStartupPreflightFailures, runStartupPreflight } from './cli/startup-preflight.js';
|
|
29
29
|
import { runUiCommand } from './cli/ui-command.js';
|
|
30
30
|
import { loadWakeConfig } from './config/load-config.js';
|
|
31
|
+
import { createActiveRunRecovery } from './core/active-run-recovery.js';
|
|
31
32
|
import { createControlPlane } from './core/control-plane.js';
|
|
32
33
|
import { createOutboundSinkRouter, createWorkSourceFanIn } from './core/sink-router.js';
|
|
33
34
|
import { createTickRunner } from './core/tick-runner.js';
|
|
@@ -842,6 +843,12 @@ async function main() {
|
|
|
842
843
|
wakeRoot,
|
|
843
844
|
});
|
|
844
845
|
const docker = createHostDockerCli();
|
|
846
|
+
const recoverActiveRuns = createActiveRunRecovery({
|
|
847
|
+
clock: systemClock,
|
|
848
|
+
config,
|
|
849
|
+
stateStore,
|
|
850
|
+
resourceIndex: createResourceIndex({ paths: stateStore.paths }),
|
|
851
|
+
}).recoverActiveRuns;
|
|
845
852
|
const repoRoot = config.dev?.repoRoot;
|
|
846
853
|
const selfUpdate = commandArgs[0] === 'self-update' &&
|
|
847
854
|
config.dev?.mode === 'source' &&
|
|
@@ -912,6 +919,7 @@ async function main() {
|
|
|
912
919
|
docker,
|
|
913
920
|
packagedTemplatesRoot: resolve(resolvePackageRoot(), 'docker'),
|
|
914
921
|
stateStore,
|
|
922
|
+
recoverActiveRuns,
|
|
915
923
|
sleep: (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)),
|
|
916
924
|
logger: {
|
|
917
925
|
info(message) {
|
package/dist/src/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const wakeVersion = "0.2.
|
|
1
|
+
export const wakeVersion = "0.2.15+g36c8953";
|