@atolis-hq/wake 0.2.12 → 0.2.14

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.
@@ -57,6 +57,9 @@ function buildRunArgs(input) {
57
57
  }
58
58
  export function createDockerCli(deps) {
59
59
  return {
60
+ async inspectContainerImage(containerName) {
61
+ return (await deps.inspectContainerImage?.(containerName)) ?? null;
62
+ },
60
63
  async build(input) {
61
64
  const buildArgFlags = Object.entries(input.buildArgs ?? {}).flatMap(([key, value]) => [
62
65
  '--build-arg',
@@ -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,
@@ -12,6 +12,13 @@ const START_PROCESS_CHECK = [
12
12
  'tr "\\0" " " < "/proc/$pid/cmdline" | grep -F "node /app/dist/src/main.js start --wake-root /wake" >/dev/null',
13
13
  ].join(' && '),
14
14
  ];
15
+ function tagFromImage(imageRepository, image) {
16
+ if (image === null) {
17
+ return null;
18
+ }
19
+ const prefix = `${imageRepository}:`;
20
+ return image.startsWith(prefix) ? image.slice(prefix.length) : null;
21
+ }
15
22
  function readFlag(name, args) {
16
23
  const index = args.indexOf(name);
17
24
  return index === -1 ? undefined : args[index + 1];
@@ -65,10 +72,20 @@ export async function runSelfUpdateCommand(input) {
65
72
  }
66
73
  await waitForActiveRuns({
67
74
  listRunRecords: input.stateStore.listRunRecords,
75
+ ...(input.recoverActiveRuns === undefined
76
+ ? {}
77
+ : { recoverActiveRuns: input.recoverActiveRuns }),
68
78
  sleep: input.sleep,
69
79
  logger: input.logger,
70
80
  });
71
81
  const newImage = `${input.imageRepository}:${tag}`;
82
+ const previousImage = (await input.docker.inspectContainerImage?.(input.containerName)) ?? null;
83
+ const previousImageTag = tagFromImage(input.imageRepository, previousImage);
84
+ const rollbackImage = previousImage ??
85
+ (ledger.lastKnownGoodTag !== null
86
+ ? `${input.imageRepository}:${ledger.lastKnownGoodTag}`
87
+ : null);
88
+ const rollbackTag = previousImageTag ?? ledger.lastKnownGoodTag;
72
89
  const updateInput = {
73
90
  containerName: input.containerName,
74
91
  wakeRoot: input.wakeRoot,
@@ -106,9 +123,10 @@ export async function runSelfUpdateCommand(input) {
106
123
  catch (error) {
107
124
  const reason = error instanceof Error ? error.message : String(error);
108
125
  input.logger.error(`[self-update] rollout of ${tag} failed: ${reason}`);
109
- if (ledger.lastKnownGoodTag !== null) {
110
- const rollbackImage = `${input.imageRepository}:${ledger.lastKnownGoodTag}`;
111
- await input.git.checkoutTag(ledger.lastKnownGoodTag);
126
+ if (rollbackImage !== null) {
127
+ if (rollbackTag !== null) {
128
+ await input.git.checkoutTag(rollbackTag);
129
+ }
112
130
  await input.docker.update({ ...updateInput, image: rollbackImage });
113
131
  input.logger.info('[self-update] recreated rollback container; entrypoint will keep wake start running');
114
132
  await verifyResidentStart({
@@ -119,21 +137,21 @@ export async function runSelfUpdateCommand(input) {
119
137
  sleep: input.sleep,
120
138
  context: 'rollback',
121
139
  });
122
- input.logger.info(`[self-update] rolled back to ${ledger.lastKnownGoodTag}`);
140
+ input.logger.info(`[self-update] rolled back to ${rollbackTag ?? rollbackImage}`);
123
141
  }
124
142
  else {
125
143
  input.logger.error('[self-update] no previous known-good tag to roll back to');
126
144
  }
127
145
  await input.writeLedger({
128
- lastAppliedTag: ledger.lastKnownGoodTag,
129
- lastKnownGoodTag: ledger.lastKnownGoodTag,
146
+ lastAppliedTag: rollbackTag,
147
+ lastKnownGoodTag: rollbackTag,
130
148
  badTags: [...ledger.badTags, { tag, reason, recordedAt: new Date().toISOString() }],
131
149
  });
132
150
  try {
133
151
  await input.issueReporter.createIssue({
134
152
  title: `Self-update to ${tag} failed and was rolled back`,
135
153
  body: [
136
- `Automated update to \`${tag}\` failed during rollout and was rolled back to \`${ledger.lastKnownGoodTag ?? 'unknown'}\`.`,
154
+ `Automated update to \`${tag}\` failed during rollout and was rolled back to \`${rollbackTag ?? rollbackImage ?? 'unknown'}\`.`,
137
155
  '',
138
156
  '```',
139
157
  reason,
@@ -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 isStaleRunningRecord(record, now) {
11
+ async function staleReason(record, now) {
12
12
  if (record.status !== 'running') {
13
- return false;
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 true;
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 = runRecords.filter((record) => isStaleRunningRecord(record, now));
25
- for (const record of staleRecords) {
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) {
@@ -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 lockIsStale(path, staleAfterMs, now) {
78
+ export async function readFileLockStatus(path, options) {
55
79
  const metadata = await readLockMetadata(path);
56
80
  if (metadata === null) {
57
- return true;
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
- return ageMs >= staleAfterMs || !isPidAlive(metadata.pid);
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 lockIsStale(path, options.staleAfterMs, options.now ?? new Date()))) {
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';
@@ -346,6 +347,28 @@ async function inspectDockerContainer(containerName) {
346
347
  });
347
348
  });
348
349
  }
350
+ async function inspectDockerContainerImage(containerName) {
351
+ return await new Promise((resolveInspect, reject) => {
352
+ const child = spawn('docker', ['container', 'inspect', '-f', '{{.Config.Image}}', containerName], {
353
+ cwd: process.cwd(),
354
+ env: process.env,
355
+ stdio: ['ignore', 'pipe', 'ignore'],
356
+ });
357
+ let stdout = '';
358
+ child.stdout.on('data', (chunk) => {
359
+ stdout += chunk.toString();
360
+ });
361
+ child.on('error', reject);
362
+ child.on('close', (exitCode) => {
363
+ if (exitCode !== 0) {
364
+ resolveInspect(null);
365
+ return;
366
+ }
367
+ const image = stdout.trim();
368
+ resolveInspect(image.length > 0 ? image : null);
369
+ });
370
+ });
371
+ }
349
372
  async function dockerDaemonReachable() {
350
373
  return await new Promise((resolveReachable) => {
351
374
  const child = spawn('docker', ['info'], {
@@ -370,6 +393,7 @@ function createHostDockerCli() {
370
393
  run: (dockerArgs) => runCommand('docker', dockerArgs, { ...process.env, DOCKER_BUILDKIT: '1' }),
371
394
  inspectImage: inspectDockerImage,
372
395
  inspectContainer: inspectDockerContainer,
396
+ inspectContainerImage: inspectDockerContainerImage,
373
397
  spawnExec: (dockerArgs) => {
374
398
  const child = spawn('docker', dockerArgs, {
375
399
  cwd: process.cwd(),
@@ -819,6 +843,12 @@ async function main() {
819
843
  wakeRoot,
820
844
  });
821
845
  const docker = createHostDockerCli();
846
+ const recoverActiveRuns = createActiveRunRecovery({
847
+ clock: systemClock,
848
+ config,
849
+ stateStore,
850
+ resourceIndex: createResourceIndex({ paths: stateStore.paths }),
851
+ }).recoverActiveRuns;
822
852
  const repoRoot = config.dev?.repoRoot;
823
853
  const selfUpdate = commandArgs[0] === 'self-update' &&
824
854
  config.dev?.mode === 'source' &&
@@ -889,6 +919,7 @@ async function main() {
889
919
  docker,
890
920
  packagedTemplatesRoot: resolve(resolvePackageRoot(), 'docker'),
891
921
  stateStore,
922
+ recoverActiveRuns,
892
923
  sleep: (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)),
893
924
  logger: {
894
925
  info(message) {
@@ -1 +1 @@
1
- export const wakeVersion = "0.2.12+g6314122";
1
+ export const wakeVersion = "0.2.14+gb810105";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {