@evident-ai/runner-cdk 3.4.1-dev.3adba63 → 3.4.1-dev.51df4de
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 +2 -2
- package/dist/controller-lambda/handler.js +1 -1
- package/dist/evident-scale-to-zero-construct.d.ts +16 -4
- package/dist/evident-scale-to-zero-construct.js +15 -9
- package/dist/microvm/construct.js +5 -1
- package/dist/microvm/controller/shape-catalogue.d.ts +1 -1
- package/dist/microvm/controller/shape-catalogue.js +1 -1
- package/dist/microvm-image-context/Dockerfile +2 -2
- package/dist/microvm-image-context/hooks/common.sh +367 -24
- package/dist/microvm-image-context/hooks/resume +5 -1
- package/dist/microvm-image-context/hooks/run +25 -6
- package/dist/microvm-image-context/hooks/suspend +3 -0
- package/dist/microvm-image-context/hooks/terminate +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,7 +97,7 @@ the construct's own dogfooding consumer.
|
|
|
97
97
|
| `cluster`, `securityGroup`, `taskRole`, `logGroup`, `replicaBucket` | interfaces | Shared infra you create and pass in. |
|
|
98
98
|
| `image` | `ecs.ContainerImage` | Your runner image. |
|
|
99
99
|
| `agentSecret`, `wakerSecret` | `secretsmanager.ISecret` | Any Secrets Manager secret. |
|
|
100
|
-
| `availableSecretKeys` | `Set<string>` | Keys present in the agent secret.
|
|
100
|
+
| `availableSecretKeys` | `Set<string>` | Keys present in the agent secret. Every listed key is injected; `EVIDENT_AGENT_KEY` and `GH_TOKEN` are also injected for compatibility with adopters that do not enumerate their secret. |
|
|
101
101
|
|
|
102
102
|
### Two deliberate "no default" choices
|
|
103
103
|
|
|
@@ -162,7 +162,7 @@ and the agent installs on first use.
|
|
|
162
162
|
|
|
163
163
|
| MicroVM prop | Omitted behavior |
|
|
164
164
|
| --- | --- |
|
|
165
|
-
| `runnerSecret` | No GitHub or MCP credentials are exported at `/run`. |
|
|
165
|
+
| `runnerSecret` | No GitHub or MCP credentials are exported at `/run`. When supplied, `/run` reads the JSON secret and exports every non-empty value whose key is a valid environment-variable name. |
|
|
166
166
|
| `runnerOpencodeConfigPath` | OpenCode uses the baked project configuration. Relative paths resolve from the workspace; absolute paths resolve in the image. |
|
|
167
167
|
| `gitUserName` / `gitUserEmail` | The hook uses the Evident bot defaults for git identity. |
|
|
168
168
|
|
|
@@ -42866,7 +42866,7 @@ Reference: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.ht
|
|
|
42866
42866
|
// ../../node_modules/.pnpm/@aws-sdk+signature-v4-multi-region@3.996.42/node_modules/@aws-sdk/signature-v4-multi-region/dist-cjs/index.js
|
|
42867
42867
|
var require_dist_cjs23 = __commonJS({
|
|
42868
42868
|
"../../node_modules/.pnpm/@aws-sdk+signature-v4-multi-region@3.996.42/node_modules/@aws-sdk/signature-v4-multi-region/dist-cjs/index.js"(exports2) {
|
|
42869
|
-
var { SignatureV4: SignatureV43, signatureV4aContainer } =
|
|
42869
|
+
var { SignatureV4: SignatureV43, signatureV4aContainer } = require_dist_cjs2();
|
|
42870
42870
|
var signatureV4CrtContainer = {
|
|
42871
42871
|
CrtSignerV4: null
|
|
42872
42872
|
};
|
|
@@ -61,13 +61,25 @@ export type EvidentScaleToZeroConstructProps = {
|
|
|
61
61
|
replicaBucket: s3.IBucket;
|
|
62
62
|
image: ecs.ContainerImage;
|
|
63
63
|
/**
|
|
64
|
-
* Container secrets.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
64
|
+
* Container secrets. `availableSecretKeys` are injected by name; EVIDENT_AGENT_KEY
|
|
65
|
+
* and GH_TOKEN remain available to external adopters that do not enumerate keys.
|
|
66
|
+
*
|
|
67
|
+
* `agentSecret` always supplies EVIDENT_AGENT_KEY. When `capabilitySecret` is
|
|
68
|
+
* ALSO given (#1868 WI-2), every capability key (GH_TOKEN included) is injected
|
|
69
|
+
* from THAT secret instead, and `availableSecretKeys` is ignored — the two
|
|
70
|
+
* deploy targets (this one and the MicroVM stack) then read the same
|
|
71
|
+
* capability keys from the same shared secret rather than each carrying its
|
|
72
|
+
* own copy. When `capabilitySecret` is absent, behavior is unchanged: GH_TOKEN
|
|
73
|
+
* plus every `availableSecretKeys` entry, all from `agentSecret` — an external
|
|
74
|
+
* adopter passing only `agentSecret`/`availableSecretKeys` is unaffected.
|
|
67
75
|
*/
|
|
68
76
|
agentSecret: secretsmanager.ISecret;
|
|
69
|
-
/** Key names present in the agent secret
|
|
77
|
+
/** Key names present in the agent secret. Every listed key is injected into the container. */
|
|
70
78
|
availableSecretKeys: Set<string>;
|
|
79
|
+
/** The shared capability secret (#1868 WI-2). See `agentSecret`'s doc above. */
|
|
80
|
+
capabilitySecret?: secretsmanager.ISecret;
|
|
81
|
+
/** Key names present in `capabilitySecret`. Ignored when `capabilitySecret` is absent. */
|
|
82
|
+
capabilityKeys?: Set<string>;
|
|
71
83
|
/** Waker secret (EVIDENT_WAKE_SECRET), consumed by the waker at runtime. */
|
|
72
84
|
wakerSecret: secretsmanager.ISecret;
|
|
73
85
|
};
|
|
@@ -61,7 +61,7 @@ class EvidentScaleToZeroConstruct extends constructs_1.Construct {
|
|
|
61
61
|
service;
|
|
62
62
|
constructor(scope, id, props) {
|
|
63
63
|
super(scope, id);
|
|
64
|
-
const { agentName, evidentAgentId, gitRepo, gitBranch, idleTimeoutSeconds, cpu, memoryLimitMiB, resourcePrefix = DEFAULT_RESOURCE_PREFIX, envName, evidentApiUrl, evidentTunnelUrl, extraEnvironment, cluster, securityGroup, taskRole, logGroup, replicaBucket, image, agentSecret, availableSecretKeys, wakerSecret, } = props;
|
|
64
|
+
const { agentName, evidentAgentId, gitRepo, gitBranch, idleTimeoutSeconds, cpu, memoryLimitMiB, resourcePrefix = DEFAULT_RESOURCE_PREFIX, envName, evidentApiUrl, evidentTunnelUrl, extraEnvironment, cluster, securityGroup, taskRole, logGroup, replicaBucket, image, agentSecret, availableSecretKeys, capabilitySecret, capabilityKeys, wakerSecret, } = props;
|
|
65
65
|
this.replicaPrefix = `agents/${evidentAgentId}`;
|
|
66
66
|
// A plain STRING (not service.serviceName) so the container env is static and
|
|
67
67
|
// has no construct-ordering dependency on the service.
|
|
@@ -127,16 +127,22 @@ class EvidentScaleToZeroConstruct extends constructs_1.Construct {
|
|
|
127
127
|
...extraEnvironment,
|
|
128
128
|
};
|
|
129
129
|
const secrets = {
|
|
130
|
+
// Identifies the runner to Evident. Always agentSecret — capabilitySecret
|
|
131
|
+
// never carries this key (#1868 WI-2: the two are a deliberate split, not
|
|
132
|
+
// a duplication of the same data).
|
|
130
133
|
EVIDENT_AGENT_KEY: ecs.Secret.fromSecretsManager(agentSecret, 'EVIDENT_AGENT_KEY'),
|
|
131
|
-
GH_TOKEN: ecs.Secret.fromSecretsManager(agentSecret, 'GH_TOKEN'),
|
|
132
134
|
};
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
if (capabilitySecret !== undefined) {
|
|
136
|
+
for (const key of capabilityKeys ?? new Set()) {
|
|
137
|
+
secrets[key] = ecs.Secret.fromSecretsManager(capabilitySecret, key);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
// Legacy shape, unchanged: GH_TOKEN (required to clone this construct's
|
|
142
|
+
// workspace at boot) plus every availableSecretKeys entry, all from
|
|
143
|
+
// agentSecret — what every external adopter still gets.
|
|
144
|
+
secrets.GH_TOKEN = ecs.Secret.fromSecretsManager(agentSecret, 'GH_TOKEN');
|
|
145
|
+
for (const key of availableSecretKeys) {
|
|
140
146
|
secrets[key] = ecs.Secret.fromSecretsManager(agentSecret, key);
|
|
141
147
|
}
|
|
142
148
|
}
|
|
@@ -124,7 +124,11 @@ class EvidentMicrovmConstruct extends constructs_1.Construct {
|
|
|
124
124
|
...(props.runnerOpencodeConfigPath
|
|
125
125
|
? { RUNNER_OPENCODE_CONFIG: props.runnerOpencodeConfigPath }
|
|
126
126
|
: {}),
|
|
127
|
-
...(props.runnerSecret
|
|
127
|
+
...(props.runnerSecret
|
|
128
|
+
? {
|
|
129
|
+
RUNNER_SECRET_ARN: props.runnerSecret.secretArn,
|
|
130
|
+
}
|
|
131
|
+
: {}),
|
|
128
132
|
...(props.gitUserName ? { GIT_USER_NAME: props.gitUserName } : {}),
|
|
129
133
|
...(props.gitUserEmail ? { GIT_USER_EMAIL: props.gitUserEmail } : {}),
|
|
130
134
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parses the `MICROVM_SHAPES` environment variable (a JSON array, written by
|
|
3
|
-
* CloudFormation from `
|
|
3
|
+
* CloudFormation from `aws/runner-cdk/src/microvm/shapes.ts` at
|
|
4
4
|
* deploy time — see D6/D7 in the plan) into a `ShapeCatalogue` the pure
|
|
5
5
|
* decision core can query, without that core ever touching `process.env`
|
|
6
6
|
* itself.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
3
|
* Parses the `MICROVM_SHAPES` environment variable (a JSON array, written by
|
|
4
|
-
* CloudFormation from `
|
|
4
|
+
* CloudFormation from `aws/runner-cdk/src/microvm/shapes.ts` at
|
|
5
5
|
* deploy time — see D6/D7 in the plan) into a `ShapeCatalogue` the pure
|
|
6
6
|
* decision core can query, without that core ever touching `process.env`
|
|
7
7
|
* itself.
|
|
@@ -171,10 +171,10 @@ ENV HOME=/home/runner
|
|
|
171
171
|
# permanent no-op and every VM from this snapshot shares one machine id. Empty
|
|
172
172
|
# is the correct unset state — it is the absence of an identity, so nothing
|
|
173
173
|
# per-VM-unique enters the shared snapshot.
|
|
174
|
-
RUN mkdir -p /var/lib/runner-pg /var/lib/dbus \
|
|
174
|
+
RUN mkdir -p /var/lib/runner-pg /var/lib/dbus /home/runner/.local/state/evident \
|
|
175
175
|
&& install -o runner -g runner -m 0644 /dev/null /etc/machine-id \
|
|
176
176
|
&& install -o runner -g runner -m 0644 /dev/null /var/lib/dbus/machine-id \
|
|
177
|
-
|
|
177
|
+
&& chown -R runner:runner /var/lib/runner-pg /home/runner
|
|
178
178
|
COPY --chown=10001:10001 repo ${WORKSPACE}
|
|
179
179
|
WORKDIR ${WORKSPACE}
|
|
180
180
|
USER runner
|
|
@@ -24,6 +24,8 @@ CONTEXT_FILE="/dev/shm/evident-run-context"
|
|
|
24
24
|
TUNNEL_PID_FILE="/dev/shm/evident-tunnel.pid"
|
|
25
25
|
OPENCODE_PID_FILE="/dev/shm/evident-opencode.pid"
|
|
26
26
|
LITESTREAM_PID_FILE="/dev/shm/evident-litestream.pid"
|
|
27
|
+
CREDS_SYNC_PID_FILE="/dev/shm/evident-creds-sync.pid"
|
|
28
|
+
CREDS_SYNC_LAST_ERROR_FILE="/dev/shm/evident-creds-sync.last-error"
|
|
27
29
|
|
|
28
30
|
# Where the runner's credential store lives inside the durable-state bucket.
|
|
29
31
|
# The BUCKET is the same for every VM from an image version, so the stack bakes
|
|
@@ -50,14 +52,49 @@ LITESTREAM_CONFIG_FILE="/dev/shm/evident-litestream.yml"
|
|
|
50
52
|
# earlier boot cannot silently disable replication for the VM's whole life.
|
|
51
53
|
SESSION_DB_NO_REPLICATE_MARKER="/dev/shm/evident-session-db-no-replicate"
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
hook_name() { printf '%s' "${0##*/}"; }
|
|
56
|
+
log() { echo "[hook:$(hook_name)] $*"; }
|
|
57
|
+
warn() { echo "[hook:$(hook_name)] $*" >&2; }
|
|
58
|
+
error() { echo "[hook:$(hook_name)] ERROR: $*" >&2; }
|
|
59
|
+
|
|
60
|
+
session_db_recovery_report_path() {
|
|
61
|
+
local path="${EVIDENT_SESSION_DB_RECOVERY_REPORT:-}"
|
|
62
|
+
path="${path#"${path%%[![:space:]]*}"}"
|
|
63
|
+
path="${path%"${path##*[![:space:]]}"}"
|
|
64
|
+
if [ -n "${path}" ]; then
|
|
65
|
+
printf '%s\n' "${path}"
|
|
66
|
+
return 0
|
|
67
|
+
fi
|
|
68
|
+
local home="${HOME:-}"
|
|
69
|
+
home="${home#"${home%%[![:space:]]*}"}"
|
|
70
|
+
home="${home%"${home##*[![:space:]]}"}"
|
|
71
|
+
[ -n "${home}" ] || return 1
|
|
72
|
+
printf '%s\n' "${home}/.local/state/evident/session-db-recovery.jsonl"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
report_session_db_giveup() {
|
|
76
|
+
local reason="$1" exit_code="${2:-null}" outcome path at
|
|
77
|
+
case "${reason}" in
|
|
78
|
+
synchroniser_config_unresolved|synchroniser_config_unevaluable|synchroniser_config_incomplete|litestream_config_unavailable|classification_fatal) outcome="restore_misconfigured" ;;
|
|
79
|
+
restore_deadline_exceeded|restore_tool_unusable|classification_unrecognised) outcome="fresh_session_db" ;;
|
|
80
|
+
*) warn "SESSION-DB-RECOVERY-REPORT-UNKNOWN-REASON: ${reason}"; return 0 ;;
|
|
81
|
+
esac
|
|
82
|
+
if ! path="$(session_db_recovery_report_path)"; then
|
|
83
|
+
warn "SESSION-DB-RECOVERY-REPORT-PATH-UNAVAILABLE"
|
|
84
|
+
return 0
|
|
85
|
+
fi
|
|
86
|
+
at="${EPOCHREALTIME:-}"
|
|
87
|
+
[ -n "${at}" ] || warn "SESSION-DB-RECOVERY-REPORT-TIMESTAMP-UNAVAILABLE"
|
|
88
|
+
printf '{"v":1,"event":"session_db_recovery","at":"%s","stage":"restore","outcome":"%s","severity":"error","reason":"%s","litestream_exit_code":%s,"attempt":null,"replica_objects":null,"replica_bytes":null,"quarantine_destination":null,"quarantined_objects":null,"quarantine_failed_objects":null,"quarantined_bytes":null,"verified_restore_point":null,"restore_points_tried":null,"replication_suspended":true}\n' "${at}" "${outcome}" "${reason}" "${exit_code}" >>"${path}" || warn "SESSION-DB-RECOVERY-REPORT-APPEND-FAILED"
|
|
89
|
+
return 0
|
|
90
|
+
}
|
|
56
91
|
|
|
57
92
|
# Returns the CLI's own exit code. Domain outcomes (nothing persisted yet, a
|
|
58
93
|
# corrupt object) are LOGGED and exit 0, the predicates answer "no" with 10, and
|
|
59
94
|
# `session-db-classify`'s three typed answers are 30 (fatal)/31 (replica
|
|
60
|
-
# unusable)/32 (retry)
|
|
95
|
+
# unusable)/32 (retry), extended by `session-db-verify`'s 33 (integrity
|
|
96
|
+
# exhausted, replica separated and local disposed) / 34 (could not prove
|
|
97
|
+
# separation or disposal) — see `runner/synchroniser/src/cli.ts`'s own
|
|
61
98
|
# comment for what each means, not restated here. Any OTHER non-zero status
|
|
62
99
|
# means the tool itself broke, which is the only case worth an ERROR here —
|
|
63
100
|
# EXCEPT 124/137 (a `timeout` deadline/SIGKILL) when the caller asked for one:
|
|
@@ -83,7 +120,7 @@ run_synchroniser() {
|
|
|
83
120
|
[ -n "${deadline}" ] && launcher=(timeout -k "${CREDENTIAL_RESTORE_KILL_GRACE_SECONDS}" "${deadline}")
|
|
84
121
|
"${launcher[@]}" "${SYNCHRONISER}" "${call_args[@]}" || rc=$?
|
|
85
122
|
case "${rc}" in
|
|
86
|
-
0 | 10 | 30 | 31 | 32) ;;
|
|
123
|
+
0 | 10 | 30 | 31 | 32 | 33 | 34) ;;
|
|
87
124
|
124 | 137)
|
|
88
125
|
[ -n "${deadline}" ] || error "synchroniser '${call_args[*]}' exited ${rc}; the '${SYNCHRONISER}' command is missing from PATH, corrupt, or it threw"
|
|
89
126
|
;;
|
|
@@ -270,19 +307,26 @@ bounded_restore_call() {
|
|
|
270
307
|
}
|
|
271
308
|
|
|
272
309
|
fetch_runner_secret() {
|
|
273
|
-
local step_started_s="$1" remaining rc=0 payload
|
|
310
|
+
local step_started_s="$1" remaining rc=0 payload stderr_file started_ms populated=0 skipped=0 key value
|
|
274
311
|
if [ -z "${RUNNER_SECRET_ARN:-}" ]; then
|
|
275
312
|
log "runner secret is not configured; continuing without GitHub and MCP credentials"
|
|
276
313
|
return 0
|
|
277
314
|
fi
|
|
315
|
+
# From here, RUNNER_SECRET_ARN IS configured: every failure branch below
|
|
316
|
+
# returns 1, not 0. Process env is fixed at spawn — nothing later in this
|
|
317
|
+
# VM's life can retry a fetch that fails here — so a VM that was promised
|
|
318
|
+
# these credentials and never got them is contrary evidence it can do the
|
|
319
|
+
# job it was launched for, exactly the class check_runner_key already treats
|
|
320
|
+
# as fatal (#1997): fail /run now and let the lifecycle machinery replace
|
|
321
|
+
# this VM, rather than run the whole session silently degraded.
|
|
278
322
|
remaining="$(remaining_credential_budget "${step_started_s}")"
|
|
279
323
|
if [ "${remaining}" -lt 1 ]; then
|
|
280
324
|
warn "CREDENTIAL-RESTORE-SKIPPED: runner-secret skipped; the ${CREDENTIAL_RESTORE_DEADLINE_SECONDS}s credential-restore budget is exhausted"
|
|
281
|
-
return
|
|
325
|
+
return 1
|
|
282
326
|
fi
|
|
283
327
|
if ! stderr_file="$(mktemp /dev/shm/runner-secret-stderr.XXXXXX)"; then
|
|
284
328
|
warn "RUNNER-SECRET-STDERR-UNAVAILABLE: could not allocate diagnostic storage; continuing without runner credentials"
|
|
285
|
-
return
|
|
329
|
+
return 1
|
|
286
330
|
fi
|
|
287
331
|
started_ms="$(now_ms)"
|
|
288
332
|
payload="$(timeout -k "${CREDENTIAL_RESTORE_KILL_GRACE_SECONDS}" "${remaining}" aws secretsmanager get-secret-value --secret-id "${RUNNER_SECRET_ARN}" --query SecretString --output text 2>"${stderr_file}")" || rc=$?
|
|
@@ -290,33 +334,31 @@ fetch_runner_secret() {
|
|
|
290
334
|
if [ "${rc}" -eq 124 ] || [ "${rc}" -eq 137 ]; then
|
|
291
335
|
warn "CREDENTIAL-RESTORE-TIMEOUT: runner-secret did not finish within its ${remaining}s share of the ${CREDENTIAL_RESTORE_DEADLINE_SECONDS}s credential-restore deadline; continuing without it"
|
|
292
336
|
rm -f "${stderr_file}"
|
|
293
|
-
return
|
|
337
|
+
return 1
|
|
294
338
|
fi
|
|
295
339
|
if [ "${rc}" -ne 0 ]; then
|
|
296
340
|
warn "RUNNER-SECRET-UNREADABLE: $(<"${stderr_file}")"
|
|
297
341
|
rm -f "${stderr_file}"
|
|
298
|
-
return
|
|
342
|
+
return 1
|
|
299
343
|
fi
|
|
300
344
|
rm -f "${stderr_file}"
|
|
301
345
|
if ! jq -e 'type == "object"' >/dev/null 2>&1 <<<"${payload}"; then
|
|
302
346
|
warn "RUNNER-SECRET-UNPARSEABLE: secret value is not a JSON object"
|
|
303
347
|
return 0
|
|
304
348
|
fi
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
populated=$((populated + 1))
|
|
311
|
-
populated_keys+=("${key}")
|
|
312
|
-
else
|
|
313
|
-
missing=$((missing + 1))
|
|
349
|
+
while IFS= read -r -d '' key && IFS= read -r -d '' value; do
|
|
350
|
+
if [[ ! "${key}" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
|
|
351
|
+
warn "RUNNER-SECRET-KEY-SKIPPED: ${key@Q} is not a valid environment variable name"
|
|
352
|
+
skipped=$((skipped + 1))
|
|
353
|
+
continue
|
|
314
354
|
fi
|
|
315
|
-
|
|
355
|
+
export "${key}=${value}"
|
|
356
|
+
populated=$((populated + 1))
|
|
357
|
+
done < <(jq -j 'to_entries[] | select(.value | type == "string" and length > 0) | .key, "\u0000", .value, "\u0000"' <<<"${payload}")
|
|
316
358
|
if [ "${populated}" -eq 0 ]; then
|
|
317
|
-
warn "RUNNER-SECRET-UNPOPULATED: populate the runner secret as documented in infrastructure/evident-
|
|
359
|
+
warn "RUNNER-SECRET-UNPOPULATED: populate the runner secret as documented in infrastructure/evident-runner/MICROVM.md"
|
|
318
360
|
else
|
|
319
|
-
log "RUNNER-SECRET-OK:
|
|
361
|
+
log "RUNNER-SECRET-OK: exported ${populated} secret values; skipped ${skipped} invalid environment variable names"
|
|
320
362
|
fi
|
|
321
363
|
return 0
|
|
322
364
|
}
|
|
@@ -328,7 +370,10 @@ restore_credentials() {
|
|
|
328
370
|
|
|
329
371
|
load_state_config || return 1
|
|
330
372
|
# This shares the existing bounded window so /run's worst-case duration does not grow.
|
|
331
|
-
fetch_runner_secret
|
|
373
|
+
# Fatal when RUNNER_SECRET_ARN is configured (see fetch_runner_secret's own
|
|
374
|
+
# comment); a no-ARN self-hosted runner already returned 0 above and never
|
|
375
|
+
# reaches this `||`.
|
|
376
|
+
fetch_runner_secret "${step_started_s}" || return 1
|
|
332
377
|
bounded_restore_call restore-claude "${step_started_s}" restore claude || return 1
|
|
333
378
|
bounded_restore_call restore-opencode "${step_started_s}" restore opencode || return 1
|
|
334
379
|
|
|
@@ -352,7 +397,7 @@ restore_credentials() {
|
|
|
352
397
|
(neither claude/credentials.json nor opencode/auth.json yielded valid JSON) and neither \
|
|
353
398
|
ANTHROPIC_API_KEY nor OPENAI_API_KEY is set. This VM boots and connects; a turn that needs \
|
|
354
399
|
a model provider fails until one is connected. See 'Seeding a credential store' in \
|
|
355
|
-
infrastructure/evident-
|
|
400
|
+
infrastructure/evident-runner/MICROVM.md."
|
|
356
401
|
;;
|
|
357
402
|
124 | 137)
|
|
358
403
|
warn "CREDENTIAL-RESTORE-TIMEOUT: model-auth-ready did not finish within its ${auth_remaining}s share of the ${CREDENTIAL_RESTORE_DEADLINE_SECONDS}s credential-restore deadline; continuing without it"
|
|
@@ -482,6 +527,35 @@ prewarm_litestream() {
|
|
|
482
527
|
log "pre-warming ${binary} in the background"
|
|
483
528
|
}
|
|
484
529
|
|
|
530
|
+
# Reads the aws CLI v2 install tree into the page cache, in the background, so
|
|
531
|
+
# fetch_runner_secret's first `aws secretsmanager get-secret-value` does not
|
|
532
|
+
# pay first-touch I/O on the critical path.
|
|
533
|
+
#
|
|
534
|
+
# A single `cat` of the `aws` entrypoint (litestream's pattern above) is NOT
|
|
535
|
+
# enough here: v2 ships as a real Python distribution (~7,500 files under the
|
|
536
|
+
# resolved binary's own directory), and a cold invocation demand-pages a
|
|
537
|
+
# scattered set of them (botocore's endpoints.json/partitions.json, service
|
|
538
|
+
# model JSON, shared libs) — one boot measured #1997's fetch at 7,243ms cold
|
|
539
|
+
# vs ~400ms warm, an ~18x gap the litestream read-ahead trick alone cannot
|
|
540
|
+
# close. `find -exec cat` walks that whole tree instead of one file.
|
|
541
|
+
prewarm_aws_cli() {
|
|
542
|
+
local binary tree
|
|
543
|
+
binary="$(command -v aws 2>/dev/null || true)"
|
|
544
|
+
if [ -z "${binary}" ]; then
|
|
545
|
+
warn "aws CLI is not on PATH; skipping the boot pre-warm"
|
|
546
|
+
return 0
|
|
547
|
+
fi
|
|
548
|
+
tree="$(dirname "$(readlink -f "${binary}")")"
|
|
549
|
+
|
|
550
|
+
(
|
|
551
|
+
local started_ms rc=0
|
|
552
|
+
started_ms="$(now_ms)"
|
|
553
|
+
find "${tree}" -type f -exec cat {} + >/dev/null 2>&1 || rc=$?
|
|
554
|
+
log_elapsed_since aws-cli-prewarm "${started_ms}" "${rc}"
|
|
555
|
+
) &
|
|
556
|
+
log "pre-warming ${tree} in the background"
|
|
557
|
+
}
|
|
558
|
+
|
|
485
559
|
# Generates ${LITESTREAM_CONFIG_FILE} from runner-synchroniser's own renderer
|
|
486
560
|
# — the SAME config module the credential restore/sync already goes through,
|
|
487
561
|
# so there is no second copy of the bucket/prefix/path logic to drift
|
|
@@ -595,6 +669,24 @@ restore_session_db() {
|
|
|
595
669
|
# suspend/resume and a /run retry, so a marker left by an earlier boot must
|
|
596
670
|
# never silently disable replication for the rest of this VM's life.
|
|
597
671
|
rm -f "${SESSION_DB_NO_REPLICATE_MARKER}"
|
|
672
|
+
local report_path
|
|
673
|
+
if report_path="$(session_db_recovery_report_path)"; then
|
|
674
|
+
# The default report directory is provisioned in the image, so this is a
|
|
675
|
+
# no-op there; a shared-path override (EVIDENT_SESSION_DB_RECOVERY_REPORT)
|
|
676
|
+
# can name a directory that isn't, and the typed synchroniser writer
|
|
677
|
+
# already creates it for that same override (session-db-recovery-report.ts).
|
|
678
|
+
# Best-effort only: report_session_db_giveup below still warns and
|
|
679
|
+
# continues if this didn't leave a writable path. `${path%/*}` leaves a
|
|
680
|
+
# bare relative filename (no `/`) unchanged rather than reducing to `.`
|
|
681
|
+
# like dirname(1) — without the explicit check, mkdir would create a
|
|
682
|
+
# directory AT the report path itself.
|
|
683
|
+
local report_dir="${report_path%/*}"
|
|
684
|
+
[ "${report_dir}" = "${report_path}" ] && report_dir="."
|
|
685
|
+
[ -d "${report_dir}" ] || mkdir -p "${report_dir}" 2>/dev/null || true
|
|
686
|
+
: >"${report_path}" || warn "SESSION-DB-RECOVERY-REPORT-TRUNCATE-FAILED"
|
|
687
|
+
else
|
|
688
|
+
warn "SESSION-DB-RECOVERY-REPORT-PATH-UNAVAILABLE"
|
|
689
|
+
fi
|
|
598
690
|
|
|
599
691
|
# Config resolved ONCE here, in the synchroniser (config.ts owns
|
|
600
692
|
# OPENCODE_DB_PATH; no second copy of that path here), exactly as
|
|
@@ -611,6 +703,7 @@ restore_session_db() {
|
|
|
611
703
|
log_elapsed_since session-db-env "${env_started_ms}" "${env_rc}"
|
|
612
704
|
if [ "${env_rc}" -ne 0 ]; then
|
|
613
705
|
mark_no_replicate "could not resolve the runner-synchroniser configuration (see the ERROR above)"
|
|
706
|
+
report_session_db_giveup synchroniser_config_unresolved
|
|
614
707
|
return 0
|
|
615
708
|
fi
|
|
616
709
|
# Guarded for the SAME reason as the substitution above, which is easy to miss:
|
|
@@ -621,6 +714,7 @@ restore_session_db() {
|
|
|
621
714
|
# it is the whole lifecycle transition, so Q3 makes it a give-up instead.
|
|
622
715
|
eval "${synchroniser_env}" || {
|
|
623
716
|
mark_no_replicate "the runner-synchroniser configuration could not be evaluated; the installed bundle likely does not match this hook"
|
|
717
|
+
report_session_db_giveup synchroniser_config_unevaluable
|
|
624
718
|
return 0
|
|
625
719
|
}
|
|
626
720
|
|
|
@@ -635,6 +729,7 @@ restore_session_db() {
|
|
|
635
729
|
# be actively wrong, not merely absent).
|
|
636
730
|
if [ -z "${OPENCODE_DB_PATH+x}" ]; then
|
|
637
731
|
mark_no_replicate "run_synchroniser env did not define OPENCODE_DB_PATH; the installed runner-synchroniser build likely does not match this hook"
|
|
732
|
+
report_session_db_giveup synchroniser_config_incomplete
|
|
638
733
|
return 0
|
|
639
734
|
fi
|
|
640
735
|
|
|
@@ -652,6 +747,7 @@ restore_session_db() {
|
|
|
652
747
|
|
|
653
748
|
if ! ensure_litestream_config; then
|
|
654
749
|
mark_no_replicate "could not generate ${LITESTREAM_CONFIG_FILE} (see the error above)"
|
|
750
|
+
report_session_db_giveup litestream_config_unavailable
|
|
655
751
|
return 0
|
|
656
752
|
fi
|
|
657
753
|
|
|
@@ -670,12 +766,14 @@ restore_session_db() {
|
|
|
670
766
|
124 | 137)
|
|
671
767
|
discard_session_db_debris
|
|
672
768
|
mark_no_replicate "SESSION-DB-RESTORE-TRUNCATED: litestream restore did not finish within the ${SESSION_DB_RESTORE_DEADLINE_SECONDS}s deadline (+${SESSION_DB_RESTORE_KILL_GRACE_SECONDS}s kill grace), ${SECONDS}s into the hook; opencode starts with a fresh session DB and nothing is replicated this boot"
|
|
769
|
+
report_session_db_giveup restore_deadline_exceeded "${restore_rc}"
|
|
673
770
|
return 0
|
|
674
771
|
;;
|
|
675
772
|
125 | 126 | 127)
|
|
676
773
|
error "litestream restore could not even run (timeout exited ${restore_rc})"
|
|
677
774
|
discard_session_db_debris
|
|
678
775
|
mark_no_replicate "restore tool is broken (timeout exited ${restore_rc}); opencode starts with a fresh session DB and nothing is replicated this boot"
|
|
776
|
+
report_session_db_giveup restore_tool_unusable "${restore_rc}"
|
|
679
777
|
return 0
|
|
680
778
|
;;
|
|
681
779
|
esac
|
|
@@ -706,11 +804,13 @@ restore_session_db() {
|
|
|
706
804
|
# fresh rather than fail /run.
|
|
707
805
|
discard_session_db_debris
|
|
708
806
|
mark_no_replicate "session-db-classify returned fatal (30); see the FATAL message above"
|
|
807
|
+
report_session_db_giveup classification_fatal
|
|
709
808
|
;;
|
|
710
809
|
*)
|
|
711
810
|
# run_synchroniser already logged the "tool broke" ERROR for this.
|
|
712
811
|
discard_session_db_debris
|
|
713
812
|
mark_no_replicate "session-db-classify exited ${classify_rc}, which is none of its documented answers"
|
|
813
|
+
report_session_db_giveup classification_unrecognised
|
|
714
814
|
;;
|
|
715
815
|
esac
|
|
716
816
|
|
|
@@ -718,6 +818,99 @@ restore_session_db() {
|
|
|
718
818
|
}
|
|
719
819
|
# --- Session DB restore (end) -----------------------------------------------
|
|
720
820
|
|
|
821
|
+
# --- Session DB integrity verification (#1868 WI-4) --------------------------
|
|
822
|
+
#
|
|
823
|
+
# The restore loop above only proves litestream could WRITE a file; it never
|
|
824
|
+
# proves the file is intact (#1345: a leaf-page-corrupt-but-openable DB
|
|
825
|
+
# re-restores unchanged forever). `session-db-verify` runs a real `PRAGMA
|
|
826
|
+
# integrity_check` and, on failure, walks retained restore points back until
|
|
827
|
+
# one passes — the MicroVM side of the identical check
|
|
828
|
+
# runner/docker-images/fargate/entrypoint.sh already runs after ITS restore
|
|
829
|
+
# loop, through the same runner-synchroniser CLI.
|
|
830
|
+
|
|
831
|
+
# Bounded by `timed_synchroniser`, not an external `timeout` around
|
|
832
|
+
# `run_synchroniser` itself (a shell function, not an exported binary — that
|
|
833
|
+
# would fail with rc 127, silently mis-triggering the allowlist's "tool
|
|
834
|
+
# broke" branch). The grace period is `CREDENTIAL_RESTORE_KILL_GRACE_SECONDS`
|
|
835
|
+
# (2s): `run_synchroniser`'s `timeout -k` hardcodes that one constant for
|
|
836
|
+
# every `timed_synchroniser` caller, not a value unique to this step, so the
|
|
837
|
+
# real worst case is DEADLINE + 2s, once — accounted for in
|
|
838
|
+
# hook-scripts.test.ts's budget-ladder test.
|
|
839
|
+
#
|
|
840
|
+
# 4s, NOT measured on this platform: #930's synchroniser-call sample put a
|
|
841
|
+
# single call at ~1.2s (a node cold start), and this ONE call also runs a
|
|
842
|
+
# PRAGMA integrity_check that scales with the restored DB's size, which #930
|
|
843
|
+
# never exercised. The 124/137 branch below is the DESIGNED-FOR outcome on a
|
|
844
|
+
# large DB, not an edge case — SYNCHRONISER-TIMING op=session-db-verify is
|
|
845
|
+
# what should actually size this once real boots report it.
|
|
846
|
+
SESSION_DB_VERIFY_DEADLINE_SECONDS="${EVIDENT_SESSION_DB_VERIFY_DEADLINE_SECONDS:-4}"
|
|
847
|
+
|
|
848
|
+
verify_session_db() {
|
|
849
|
+
# Guards mirror start_litestream's first three, in the same order and for
|
|
850
|
+
# the same reason: verifying a DB this boot already decided not to
|
|
851
|
+
# replicate (or never restored at all) changes nothing about how /run
|
|
852
|
+
# proceeds, and would spend budget only to report on a moot outcome.
|
|
853
|
+
if [ -z "${PERSISTENCE_BUCKET:-}" ]; then
|
|
854
|
+
log "skipping session-DB verification: persistence is disabled"
|
|
855
|
+
return 0
|
|
856
|
+
fi
|
|
857
|
+
|
|
858
|
+
if [ -e "${SESSION_DB_NO_REPLICATE_MARKER}" ]; then
|
|
859
|
+
log "skipping session-DB verification: this boot's session DB was not proven safe to replicate (see the SESSION-DB-* warning above)"
|
|
860
|
+
return 0
|
|
861
|
+
fi
|
|
862
|
+
|
|
863
|
+
if [ ! -s "${LITESTREAM_CONFIG_FILE}" ]; then
|
|
864
|
+
log "skipping session-DB verification: no usable ${LITESTREAM_CONFIG_FILE}"
|
|
865
|
+
return 0
|
|
866
|
+
fi
|
|
867
|
+
|
|
868
|
+
# Exported below this step's own deadline so the CLI's in-process walkback
|
|
869
|
+
# loop gives up on its own before the process-group kill lands — its 180s
|
|
870
|
+
# default (config.ts) is far outside this one step's slice of the hook's
|
|
871
|
+
# SIGTERM budget.
|
|
872
|
+
local verify_rc=0
|
|
873
|
+
EVIDENT_SESSION_DB_WALKBACK_BUDGET_SECONDS="${SESSION_DB_VERIFY_DEADLINE_SECONDS}" \
|
|
874
|
+
timed_synchroniser session-db-verify "${SESSION_DB_VERIFY_DEADLINE_SECONDS}" \
|
|
875
|
+
session-db-verify "${LITESTREAM_CONFIG_FILE}" || verify_rc=$?
|
|
876
|
+
|
|
877
|
+
case "${verify_rc}" in
|
|
878
|
+
0) ;; # verified intact, or nothing to verify yet — the CLI already logged which
|
|
879
|
+
33)
|
|
880
|
+
# Integrity exhausted, but the classifier already proved the corrupt
|
|
881
|
+
# replica separated and the local copy disposed of — booting with a
|
|
882
|
+
# fresh DB and a new backup chain is safe, exactly like ECS's own `33)`.
|
|
883
|
+
log "SESSION-DB-INTEGRITY-EXHAUSTED: booting continues and litestream still replicates, starting an empty backup chain after the corrupt replica was separated."
|
|
884
|
+
;;
|
|
885
|
+
34)
|
|
886
|
+
# The ONE deliberate exception to "nothing about the session DB may
|
|
887
|
+
# ever fail /run" (Q3): a 34 means separation/disposal could NOT be
|
|
888
|
+
# proven, so continuing would hand opencode a DB it may not be safe to
|
|
889
|
+
# open or write — the same evidence-quality bar `check_runner_key`
|
|
890
|
+
# already applies one step earlier in this hook (contrary evidence,
|
|
891
|
+
# not absent evidence, is what's fatal). The CLI already logged
|
|
892
|
+
# SESSION-DB-REPLICA-SEPARATION-UNVERIFIED / SESSION-DB-LOCAL-DISCARD-FAILED.
|
|
893
|
+
error "SESSION-DB-INTEGRITY-EXHAUSTED: the corrupt session DB could not be proven separated from the active backup prefix or removed from disk, so nothing will be started (see the ERROR above)."
|
|
894
|
+
return 1
|
|
895
|
+
;;
|
|
896
|
+
124 | 137)
|
|
897
|
+
# The designed-for outcome on a large DB (see the deadline comment
|
|
898
|
+
# above), not a broken tool: continue with the restored DB exactly as
|
|
899
|
+
# ECS's own `*)` branch does for an unexpected code.
|
|
900
|
+
warn "SESSION-DB-VERIFY-TIMEOUT: verification did not finish within its ${SESSION_DB_VERIFY_DEADLINE_SECONDS}s deadline; continuing with the restored opencode.db as-is, unverified"
|
|
901
|
+
;;
|
|
902
|
+
*)
|
|
903
|
+
# run_synchroniser already logged the "tool broke" ERROR for this. A
|
|
904
|
+
# broken verifier must not turn a boot that works today into a
|
|
905
|
+
# crash-loop.
|
|
906
|
+
warn "SESSION-DB-VERIFY-UNKNOWN: session-db-verify exited ${verify_rc}, which is none of its documented answers; continuing with the restored opencode.db as-is"
|
|
907
|
+
;;
|
|
908
|
+
esac
|
|
909
|
+
|
|
910
|
+
return 0
|
|
911
|
+
}
|
|
912
|
+
# --- Session DB integrity verification (end) ---------------------------------
|
|
913
|
+
|
|
721
914
|
# `kill -0` answers "does this pid exist", which is not the question any caller
|
|
722
915
|
# here is asking. A process that has exited but has not been reaped — a zombie —
|
|
723
916
|
# still exists, so `kill -0` reports a corpse as ALIVE. That condition is the
|
|
@@ -810,6 +1003,7 @@ regenerate_machine_id() {
|
|
|
810
1003
|
tunnel_is_running() { is_running "${TUNNEL_PID_FILE}"; }
|
|
811
1004
|
opencode_is_running() { is_running "${OPENCODE_PID_FILE}"; }
|
|
812
1005
|
litestream_is_running() { is_running "${LITESTREAM_PID_FILE}"; }
|
|
1006
|
+
creds_sync_is_running() { is_running "${CREDS_SYNC_PID_FILE}"; }
|
|
813
1007
|
|
|
814
1008
|
# `jq -e` alone is not enough: its exit status reflects the LAST OUTPUT VALUE,
|
|
815
1009
|
# and an interpolation of a missing field is still a non-empty string, so a
|
|
@@ -1030,6 +1224,155 @@ kill_litestream() {
|
|
|
1030
1224
|
}
|
|
1031
1225
|
# --- litestream replicate (end) ----------------------------------------------
|
|
1032
1226
|
|
|
1227
|
+
# --- credential sync loop (#1868 WI-3, ECS parity) ---------------------------
|
|
1228
|
+
#
|
|
1229
|
+
# sync_credentials (above) covers the three boundary flushes /run's restore,
|
|
1230
|
+
# /suspend and /terminate already call. What it does NOT cover is a VM that
|
|
1231
|
+
# runs for a long time between those boundaries: a provider re-authenticated
|
|
1232
|
+
# through the proxied UI hours into a run would sit unflushed until the next
|
|
1233
|
+
# suspend/terminate, and a VM that dies without one (a crash, an OOM kill)
|
|
1234
|
+
# loses everything since boot. runner/docker-images/fargate/entrypoint.sh's
|
|
1235
|
+
# own sync_credentials_loop is the ECS side of the identical gap; this is the
|
|
1236
|
+
# same fix, backgrounded the same way as start_opencode/start_litestream so it
|
|
1237
|
+
# outlives this hook process, `( … ) &` rather than `setsid`: a plain
|
|
1238
|
+
# backgrounded subshell is reparented to init and keeps running once its
|
|
1239
|
+
# parent hook script exits (verified: PPID=1, still alive, with no controlling
|
|
1240
|
+
# terminal in this image to send it a stray SIGHUP), and it inherits every
|
|
1241
|
+
# function this file defines, so it can call run_synchroniser directly with no
|
|
1242
|
+
# re-exec.
|
|
1243
|
+
|
|
1244
|
+
# Bounded confirmation window `stop_credential_sync` polls after signalling the
|
|
1245
|
+
# loop, sized against the SIGTERM budget ladder (#812 WI-4's
|
|
1246
|
+
# hook-scripts.test.ts): /terminate's own steps already use 49 of the 55s
|
|
1247
|
+
# ceiling, leaving 6s of headroom — this matches the *_KILL_GRACE_SECONDS
|
|
1248
|
+
# convention (CREDENTIAL_RESTORE_KILL_GRACE_SECONDS,
|
|
1249
|
+
# SESSION_DB_RESTORE_KILL_GRACE_SECONDS, both 2s) rather than a longer
|
|
1250
|
+
# drain-style wait, since the loop's current child is one fast
|
|
1251
|
+
# `run_synchroniser sync-once` call (#930: ~1.2s measured), not a writer
|
|
1252
|
+
# needing a graceful drain.
|
|
1253
|
+
CREDS_SYNC_STOP_WAIT_SECONDS=2
|
|
1254
|
+
|
|
1255
|
+
# Best-effort per tick, exactly like sync_credentials above: a failed tick
|
|
1256
|
+
# must never end the loop, or a single transient S3 error would silently
|
|
1257
|
+
# disable sync for the rest of the VM's life.
|
|
1258
|
+
start_credential_sync() {
|
|
1259
|
+
if [ -z "${PERSISTENCE_BUCKET:-}" ]; then
|
|
1260
|
+
warn "CREDS-SYNC-DISABLED: LITESTREAM_BUCKET/LITESTREAM_PREFIX are not both set; no interval credential sync this boot."
|
|
1261
|
+
return 0
|
|
1262
|
+
fi
|
|
1263
|
+
|
|
1264
|
+
if creds_sync_is_running; then
|
|
1265
|
+
warn "credential sync loop already running (pid $(cat "${CREDS_SYNC_PID_FILE}")); reusing it"
|
|
1266
|
+
return 0
|
|
1267
|
+
fi
|
|
1268
|
+
|
|
1269
|
+
# CREDS_SYNC_INTERVAL is exported by restore_session_db's `eval "$(run_synchroniser env)"`
|
|
1270
|
+
# on /run (config.ts's own default is 60s), but that eval can fail or be skipped by an
|
|
1271
|
+
# earlier give-up — never leave the loop unbound under set -u for a value with a safe,
|
|
1272
|
+
# named fallback (unlike OPENCODE_DB_PATH, a guessed sync cadence is not actively wrong).
|
|
1273
|
+
local interval="${CREDS_SYNC_INTERVAL:-60}"
|
|
1274
|
+
if [ -z "${CREDS_SYNC_INTERVAL:-}" ]; then
|
|
1275
|
+
warn "CREDS-SYNC-INTERVAL-DEFAULTED: CREDS_SYNC_INTERVAL was not set by run_synchroniser env; using ${interval}s"
|
|
1276
|
+
fi
|
|
1277
|
+
|
|
1278
|
+
rm -f "${CREDS_SYNC_LAST_ERROR_FILE}"
|
|
1279
|
+
|
|
1280
|
+
(
|
|
1281
|
+
# Releases the fds this subshell inherited from the hook process before
|
|
1282
|
+
# settling in for the VM's whole remaining life: nothing here writes to
|
|
1283
|
+
# them (every synchroniser call already redirects its own), so there is
|
|
1284
|
+
# no reason to keep holding the hook's original stdout/stderr open. A
|
|
1285
|
+
# long-lived process that instead inherited a pipe's write end (a test
|
|
1286
|
+
# harness reading the hook's own output, for one) would keep that pipe
|
|
1287
|
+
# from ever reporting EOF — testing-guide.mdc's own lesson, and the same
|
|
1288
|
+
# reason start_opencode/start_litestream never inherit stdio either. That
|
|
1289
|
+
# redirect also means `warn`/`log`/`error` calls in here go nowhere, so a
|
|
1290
|
+
# failed sync-once is instead recorded to CREDS_SYNC_LAST_ERROR_FILE and
|
|
1291
|
+
# surfaced by stop_credential_sync, which DOES have live stdio.
|
|
1292
|
+
exec >/dev/null 2>&1 </dev/null
|
|
1293
|
+
|
|
1294
|
+
# A TERM this subshell receives (from stop_credential_sync, below) only
|
|
1295
|
+
# kills THIS wrapper by default — its currently-running child (`sleep`,
|
|
1296
|
+
# or a `run_synchroniser sync-once` call) is a separate process that
|
|
1297
|
+
# would otherwise be orphaned and keep running, free to upload STALE
|
|
1298
|
+
# credentials to S3 after the boundary flush that /suspend and
|
|
1299
|
+
# /terminate perform immediately following the stop. Tracking the
|
|
1300
|
+
# current child explicitly and forwarding the signal closes that race.
|
|
1301
|
+
creds_sync_child_pid=""
|
|
1302
|
+
trap 'trap - TERM; [ -n "${creds_sync_child_pid}" ] && kill -TERM "${creds_sync_child_pid}" 2>/dev/null; exit 0' TERM
|
|
1303
|
+
|
|
1304
|
+
while true; do
|
|
1305
|
+
sleep "${interval}" &
|
|
1306
|
+
creds_sync_child_pid=$!
|
|
1307
|
+
wait "${creds_sync_child_pid}" 2>/dev/null
|
|
1308
|
+
creds_sync_child_pid=""
|
|
1309
|
+
|
|
1310
|
+
run_synchroniser sync-once claude &
|
|
1311
|
+
creds_sync_child_pid=$!
|
|
1312
|
+
wait "${creds_sync_child_pid}" 2>/dev/null || echo "claude" >"${CREDS_SYNC_LAST_ERROR_FILE}"
|
|
1313
|
+
creds_sync_child_pid=""
|
|
1314
|
+
|
|
1315
|
+
run_synchroniser sync-once opencode &
|
|
1316
|
+
creds_sync_child_pid=$!
|
|
1317
|
+
wait "${creds_sync_child_pid}" 2>/dev/null || echo "opencode" >"${CREDS_SYNC_LAST_ERROR_FILE}"
|
|
1318
|
+
creds_sync_child_pid=""
|
|
1319
|
+
done
|
|
1320
|
+
) &
|
|
1321
|
+
|
|
1322
|
+
echo $! >"${CREDS_SYNC_PID_FILE}"
|
|
1323
|
+
log "CREDS-SYNC-STARTED: pid=$! interval=${interval}s"
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
# Signals the loop, then confirms (bounded — see CREDS_SYNC_STOP_WAIT_SECONDS)
|
|
1327
|
+
# that it and its current child are actually gone before returning: /suspend
|
|
1328
|
+
# and /terminate start their own boundary flush immediately after this call,
|
|
1329
|
+
# and an orphaned in-flight sync-once surviving past that point can overwrite
|
|
1330
|
+
# fresher credentials with stale ones. The TERM trap inside the loop (above)
|
|
1331
|
+
# forwards the signal to its current child almost instantly — this poll is a
|
|
1332
|
+
# defensive confirmation, not the primary mechanism, so it stays short; a
|
|
1333
|
+
# SIGKILL backstop covers a child that ignores TERM entirely.
|
|
1334
|
+
#
|
|
1335
|
+
# The DIED branch is a liveness report, not a no-op: every recovery/no-op path
|
|
1336
|
+
# must say what it found (development-workflow.mdc) — a stopped-before-called
|
|
1337
|
+
# loop and a died-on-its-own loop are different facts an operator needs told
|
|
1338
|
+
# apart, not the same "nothing to stop" line.
|
|
1339
|
+
stop_credential_sync() {
|
|
1340
|
+
if [ ! -s "${CREDS_SYNC_PID_FILE}" ]; then
|
|
1341
|
+
log "CREDS-SYNC-NOT-RUNNING: no credential sync loop to stop"
|
|
1342
|
+
return 0
|
|
1343
|
+
fi
|
|
1344
|
+
|
|
1345
|
+
local pid
|
|
1346
|
+
pid="$(cat "${CREDS_SYNC_PID_FILE}")"
|
|
1347
|
+
if ! process_is_alive "${pid}"; then
|
|
1348
|
+
rm -f "${CREDS_SYNC_PID_FILE}"
|
|
1349
|
+
warn "CREDS-SYNC-DIED: credential sync loop (pid=${pid}) had already exited before this stop"
|
|
1350
|
+
return 0
|
|
1351
|
+
fi
|
|
1352
|
+
|
|
1353
|
+
kill -TERM "${pid}" 2>/dev/null || true
|
|
1354
|
+
rm -f "${CREDS_SYNC_PID_FILE}"
|
|
1355
|
+
|
|
1356
|
+
local waited_ms=0
|
|
1357
|
+
while process_is_alive "${pid}" && [ "${waited_ms}" -lt $((CREDS_SYNC_STOP_WAIT_SECONDS * 1000)) ]; do
|
|
1358
|
+
sleep 0.1
|
|
1359
|
+
waited_ms=$((waited_ms + 100))
|
|
1360
|
+
done
|
|
1361
|
+
|
|
1362
|
+
if process_is_alive "${pid}"; then
|
|
1363
|
+
kill -KILL "${pid}" 2>/dev/null || true
|
|
1364
|
+
warn "CREDS-SYNC-STOP-TIMEOUT: pid=${pid} still alive after ${CREDS_SYNC_STOP_WAIT_SECONDS}s; sent SIGKILL"
|
|
1365
|
+
fi
|
|
1366
|
+
|
|
1367
|
+
if [ -s "${CREDS_SYNC_LAST_ERROR_FILE}" ]; then
|
|
1368
|
+
warn "CREDS-SYNC-HAD-FAILURES: sync-once failed at least once for: $(tr '\n' ' ' <"${CREDS_SYNC_LAST_ERROR_FILE}")"
|
|
1369
|
+
rm -f "${CREDS_SYNC_LAST_ERROR_FILE}"
|
|
1370
|
+
fi
|
|
1371
|
+
|
|
1372
|
+
log "CREDS-SYNC-STOPPED: pid=${pid}"
|
|
1373
|
+
}
|
|
1374
|
+
# --- credential sync loop (end) -----------------------------------------------
|
|
1375
|
+
|
|
1033
1376
|
# --- flush_session_db (#812 WI-4) -------------------------------------------
|
|
1034
1377
|
#
|
|
1035
1378
|
# The checked, synchronous flush /suspend and /terminate need before they
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
# session-DB replicator /suspend stopped before the snapshot (#812 WI-4):
|
|
6
6
|
# litestream does not survive a suspend/resume freeze on this design (Q2) —
|
|
7
7
|
# /suspend stops it and /resume starts a fresh one, the same pattern already
|
|
8
|
-
# proven for the tunnel.
|
|
8
|
+
# proven for the tunnel. The interval credential sync loop (#1868 WI-3) is the
|
|
9
|
+
# same story one function over: /suspend stops it too, so a resumed VM that
|
|
10
|
+
# never restarted it here would never sync credentials again for the rest of
|
|
11
|
+
# its life.
|
|
9
12
|
set -euo pipefail
|
|
10
13
|
|
|
11
14
|
# shellcheck source=./common.sh
|
|
@@ -46,6 +49,7 @@ fi
|
|
|
46
49
|
# already-running) handle the rest — this needs no logic of its own.
|
|
47
50
|
load_state_config || warn "could not resolve durable-state config; the session DB will not resume replicating"
|
|
48
51
|
start_litestream
|
|
52
|
+
start_credential_sync
|
|
49
53
|
|
|
50
54
|
# Diagnostic-only, unlike /run's gate: a failed resume costs the user their
|
|
51
55
|
# whole session, so this never exits — it only converts a silent "resumed but
|
|
@@ -28,6 +28,7 @@ cleanup() {
|
|
|
28
28
|
stop_tunnel || warn "stop_tunnel failed while cleaning up"
|
|
29
29
|
stop_opencode || warn "stop_opencode failed while cleaning up"
|
|
30
30
|
kill_litestream || warn "kill_litestream failed while cleaning up"
|
|
31
|
+
stop_credential_sync || warn "stop_credential_sync failed while cleaning up"
|
|
31
32
|
}
|
|
32
33
|
trap cleanup EXIT
|
|
33
34
|
|
|
@@ -53,8 +54,12 @@ printf '%s\n' "${state_prefix}" >"${STATE_PREFIX_FILE}"
|
|
|
53
54
|
|
|
54
55
|
# 3 — start reading the litestream binary NOW so that read overlaps step 4,
|
|
55
56
|
# which needs a different binary (node), instead of landing inside step 5, which
|
|
56
|
-
# start_opencode has to wait for. Backgrounded and never waited on.
|
|
57
|
+
# start_opencode has to wait for. Backgrounded and never waited on. Same for the
|
|
58
|
+
# aws CLI: step 4's runner-secret fetch is its first-ever invocation in this
|
|
59
|
+
# VM's life, and #1997 measured that cold first touch alone blowing the whole
|
|
60
|
+
# credential-restore deadline.
|
|
57
61
|
prewarm_litestream
|
|
62
|
+
prewarm_aws_cli
|
|
58
63
|
|
|
59
64
|
# 4 — credential stores, restored before anything that reads them starts. Fatal
|
|
60
65
|
# only if persistence itself is unavailable (an unset bucket/prefix, or a broken
|
|
@@ -79,13 +84,22 @@ check_runner_key "${runner_key}" "${api_url}" || exit 1
|
|
|
79
84
|
restore_session_db
|
|
80
85
|
log "session DB restore done ${SECONDS}s into the hook"
|
|
81
86
|
|
|
82
|
-
# 7 —
|
|
87
|
+
# 7 — integrity-check the restored DB (#1868 WI-4), after the config file
|
|
88
|
+
# exists (verify_session_db reads it) and before anything opens the DB —
|
|
89
|
+
# the only window in which that's true. The ONE step in this sequence that
|
|
90
|
+
# can still fail /run past the runner-key gate: a 34 means the corrupt DB's
|
|
91
|
+
# separation/disposal could not be proven safe (see verify_session_db's own
|
|
92
|
+
# comment for why that's a deliberate exception to "nothing about the
|
|
93
|
+
# session DB may ever fail /run").
|
|
94
|
+
verify_session_db || exit 1
|
|
95
|
+
|
|
96
|
+
# 8 — apply the overlay before OpenCode resolves its project configuration.
|
|
83
97
|
apply_runner_opencode_config
|
|
84
98
|
|
|
85
|
-
#
|
|
99
|
+
# 9 — configure git after credentials are restored and before agent shells start.
|
|
86
100
|
configure_github_access
|
|
87
101
|
|
|
88
|
-
#
|
|
102
|
+
# 10 — opencode. Started here, not at build time: a warm process in the shared
|
|
89
103
|
# snapshot would carry its installation id and database into every VM. Not
|
|
90
104
|
# waited on: a slow opencode boot is not a reason to fail /run (the tunnel CLI
|
|
91
105
|
# auto-starts opencode when it finds none healthy,
|
|
@@ -93,14 +107,19 @@ configure_github_access
|
|
|
93
107
|
# reclaims a runner that never comes online).
|
|
94
108
|
start_opencode
|
|
95
109
|
|
|
96
|
-
#
|
|
110
|
+
# 11 — begin replicating the session DB (#812 WI-3), now that opencode has
|
|
97
111
|
# opened it and before any work can arrive over the tunnel. Bare, like
|
|
98
112
|
# restore_session_db above: start_litestream never returns non-zero (every
|
|
99
113
|
# guard inside it is its own `return 0`), so there is nothing here for
|
|
100
114
|
# `set -e` to abort on.
|
|
101
115
|
start_litestream
|
|
102
116
|
|
|
103
|
-
#
|
|
117
|
+
# 11a — the interval credential sync (#1868 WI-3), matching ECS's own
|
|
118
|
+
# post-litestream position. Bare for the same reason: every guard inside
|
|
119
|
+
# start_credential_sync is its own `return 0`.
|
|
120
|
+
start_credential_sync
|
|
121
|
+
|
|
122
|
+
# 12 — the first per-VM identity on the wire. The subshell's umask makes the file
|
|
104
123
|
# unreadable to anyone else from the moment it exists, before the key is in it.
|
|
105
124
|
(
|
|
106
125
|
umask 077
|
|
@@ -8,6 +8,9 @@ set -euo pipefail
|
|
|
8
8
|
# shellcheck source=./common.sh
|
|
9
9
|
source "$(dirname "$0")/common.sh"
|
|
10
10
|
|
|
11
|
+
# Stopped BEFORE the boundary flush (#1868 WI-3): the interval loop and this
|
|
12
|
+
# flush must not race each other on the same credential stores.
|
|
13
|
+
stop_credential_sync
|
|
11
14
|
sync_credentials
|
|
12
15
|
stop_tunnel
|
|
13
16
|
|
|
@@ -8,6 +8,9 @@ set -uo pipefail
|
|
|
8
8
|
# shellcheck source=./common.sh
|
|
9
9
|
source "$(dirname "$0")/common.sh"
|
|
10
10
|
|
|
11
|
+
# Stopped BEFORE the boundary flush (#1868 WI-3): the interval loop and this
|
|
12
|
+
# flush must not race each other on the same credential stores.
|
|
13
|
+
stop_credential_sync
|
|
11
14
|
sync_credentials
|
|
12
15
|
stop_tunnel
|
|
13
16
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evident-ai/runner-cdk",
|
|
3
|
-
"version": "3.4.1-dev.
|
|
3
|
+
"version": "3.4.1-dev.51df4de",
|
|
4
4
|
"description": "Reusable CDK constructs for an Evident agent runner: a single scale-to-zero Fargate runner (task + service + per-agent self-stop role + waker Lambda), or a per-session AWS Lambda MicroVM that boots on demand and suspends between messages. Instantiate once per agent from your own stack.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|