deepline 0.3.258 → 0.3.260
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/bundling-sources/sdk/src/api-error.ts +45 -0
- package/dist/bundling-sources/sdk/src/client.ts +98 -459
- package/dist/bundling-sources/sdk/src/http.ts +1 -34
- package/dist/bundling-sources/sdk/src/index.ts +8 -0
- package/dist/bundling-sources/sdk/src/plays/csv-data-input.ts +139 -0
- package/dist/bundling-sources/sdk/src/plays/run-request.ts +855 -0
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/sdk/src/runs/overview.ts +45 -2
- package/dist/bundling-sources/sdk/src/types.ts +5 -0
- package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +8 -0
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +42 -10
- package/dist/bundling-sources/shared_libs/play-runtime/launch-identity.ts +3 -0
- package/dist/bundling-sources/shared_libs/play-runtime/launch-source-policy.ts +23 -0
- package/dist/bundling-sources/shared_libs/play-runtime/live-state-contract.ts +3 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-admission-outcome.ts +23 -5
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/gateway-adapter.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/run-execution-state.ts +845 -0
- package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +106 -6
- package/dist/bundling-sources/shared_libs/play-runtime/run-observation.ts +440 -0
- package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +35 -2
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +66 -7
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-crash-details.ts +95 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +13 -2
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +0 -29
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-row-transition.ts +47 -2
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +6 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +475 -36
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-progress.ts +78 -11
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +550 -42
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +181 -8
- package/dist/bundling-sources/shared_libs/play-runtime/step-lifecycle-tracker.ts +0 -18
- package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +80 -7
- package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +37 -2
- package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +148 -18
- package/dist/bundling-sources/shared_libs/plays/cascade-falloff.ts +86 -0
- package/dist/bundling-sources/shared_libs/plays/docflow-unreached-reason.ts +611 -0
- package/dist/bundling-sources/shared_libs/plays/docflow.ts +21 -0
- package/dist/bundling-sources/shared_libs/plays/play-exports.ts +28 -5
- package/dist/bundling-sources/shared_libs/plays/settings-contract.ts +92 -10
- package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +5 -0
- package/dist/bundling-sources/shared_libs/plays/trigger-config-contract.ts +222 -0
- package/dist/bundling-sources/shared_libs/plays/trigger-config-route.ts +15 -0
- package/dist/cli/index.js +4957 -771
- package/dist/cli/index.mjs +4957 -771
- package/dist/client-B5XbKrrs.d.mts +6564 -0
- package/dist/client-C9Y49yya.d.ts +6564 -0
- package/dist/{compiler-manifest-C4mdRJOP.d.mts → compiler-manifest-CUuh8C3b.d.mts} +14 -1
- package/dist/{compiler-manifest-C4mdRJOP.d.ts → compiler-manifest-CUuh8C3b.d.ts} +14 -1
- package/dist/index.d.mts +7 -6275
- package/dist/index.d.ts +7 -6275
- package/dist/index.js +360 -326
- package/dist/index.mjs +360 -326
- package/dist/install-integrity.json +22 -2
- package/dist/plays/bundle-play-file.d.mts +2 -2
- package/dist/plays/bundle-play-file.d.ts +2 -2
- package/dist/plays/bundle-play-file.mjs +117 -18
- package/dist/plays/csv-data-input.d.mts +37 -0
- package/dist/plays/csv-data-input.d.ts +37 -0
- package/dist/plays/csv-data-input.js +3135 -0
- package/dist/plays/csv-data-input.mjs +3108 -0
- package/dist/plays/run-request.d.mts +6 -0
- package/dist/plays/run-request.d.ts +6 -0
- package/dist/plays/run-request.js +664 -0
- package/dist/plays/run-request.mjs +614 -0
- package/dist/release.d.mts +1 -1
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -1
- package/dist/release.mjs +1 -1
- package/package.json +12 -1
package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts
CHANGED
|
@@ -134,7 +134,10 @@ import {
|
|
|
134
134
|
cleanupPostgresSchedulerMaterializedFiles,
|
|
135
135
|
completePostgresSchedulerComputeBillingJob,
|
|
136
136
|
completePostgresSchedulerComputeBillingSessionBootstrap,
|
|
137
|
+
claimPostgresSchedulerWaitingDetachedAttemptForTerminal,
|
|
137
138
|
deferPostgresSchedulerAttemptForCapacity,
|
|
139
|
+
deferPostgresSchedulerAttemptForExecutorTokenOutage,
|
|
140
|
+
detachedRunnerCeilingDeadlineMsFromSuspension,
|
|
138
141
|
enqueuePostgresSchedulerSandboxCleanupForAttempt,
|
|
139
142
|
enqueuePostgresSchedulerSandboxCleanupForRun,
|
|
140
143
|
hasPostgresSchedulerRecoverableDetachedRunner,
|
|
@@ -225,9 +228,11 @@ import {
|
|
|
225
228
|
} from './postgres-pool';
|
|
226
229
|
import { runWithAbsurdSchedulerQuerySignal } from './absurd-pool';
|
|
227
230
|
import {
|
|
231
|
+
buildFailedTerminalSettledProgressEvents,
|
|
228
232
|
createPostgresSchedulerProgressReporter,
|
|
229
233
|
type PostgresSchedulerProgressReporter,
|
|
230
234
|
} from './postgres-progress';
|
|
235
|
+
import type { PlayRunLedgerEvent } from '../run-ledger';
|
|
231
236
|
import {
|
|
232
237
|
ABSURD_PLAY_RUN_MAX_ATTEMPTS,
|
|
233
238
|
ABSURD_PLAY_RUN_RETRY_STRATEGY,
|
|
@@ -310,6 +315,43 @@ class AttemptExecutorTokenCapacityError extends RuntimeSchedulerCapacityError {
|
|
|
310
315
|
}
|
|
311
316
|
}
|
|
312
317
|
|
|
318
|
+
/**
|
|
319
|
+
* The executor-token route was unreachable or answered with a transient
|
|
320
|
+
* front-door/server failure (transport error, timeout, 5xx, 408/429, or a
|
|
321
|
+
* non-JSON 2xx gateway page). The mint precedes any Play code, so the handler
|
|
322
|
+
* defers the durable task — bounded by wall-clock — instead of spending an
|
|
323
|
+
* engine attempt. Deterministic 4xx rejections (auth, lease fence) stay
|
|
324
|
+
* ordinary failures.
|
|
325
|
+
*/
|
|
326
|
+
export class AttemptExecutorTokenUnavailableError extends Error {
|
|
327
|
+
constructor(
|
|
328
|
+
message: string,
|
|
329
|
+
readonly status: number | null,
|
|
330
|
+
) {
|
|
331
|
+
super(message);
|
|
332
|
+
this.name = 'AttemptExecutorTokenUnavailableError';
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Wall-clock budget for one executor-token request (headers and body). */
|
|
337
|
+
const ATTEMPT_EXECUTOR_TOKEN_MINT_TIMEOUT_MS = 30_000;
|
|
338
|
+
/**
|
|
339
|
+
* Longest consecutive executor-token outage a fresh attempt may defer
|
|
340
|
+
* through. A detached-runner wake is bounded by its own persisted hard
|
|
341
|
+
* ceiling instead. Past either bound the run is terminalized loudly.
|
|
342
|
+
*/
|
|
343
|
+
export const ABSURD_EXECUTOR_TOKEN_OUTAGE_MAX_MS = 10 * 60_000;
|
|
344
|
+
const ABSURD_EXECUTOR_TOKEN_OUTAGE_DEFER_SECONDS = 5;
|
|
345
|
+
|
|
346
|
+
function isTransientExecutorTokenStatus(
|
|
347
|
+
status: number,
|
|
348
|
+
jsonBody: boolean,
|
|
349
|
+
): boolean {
|
|
350
|
+
if (status >= 500 || status === 408 || status === 429) return true;
|
|
351
|
+
// A 2xx that is not JSON is a front-door interstitial, not the route.
|
|
352
|
+
return status >= 200 && status < 300 && !jsonBody;
|
|
353
|
+
}
|
|
354
|
+
|
|
313
355
|
// No `installPgRuntimePoolDriver()` here: the runtime Postgres driver belongs to
|
|
314
356
|
// the process that owns this module's boot — `apps/absurd-runtime-worker` and
|
|
315
357
|
// `apps/absurd-release-scaler` install it at their entrypoints. `apps/deepline-api`
|
|
@@ -976,26 +1018,48 @@ export async function requestAttemptExecutorToken(input: {
|
|
|
976
1018
|
attempt: input.attempt,
|
|
977
1019
|
traceId,
|
|
978
1020
|
});
|
|
979
|
-
const
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1021
|
+
const requestSignal = AbortSignal.timeout(
|
|
1022
|
+
ATTEMPT_EXECUTOR_TOKEN_MINT_TIMEOUT_MS,
|
|
1023
|
+
);
|
|
1024
|
+
let response: Response;
|
|
1025
|
+
try {
|
|
1026
|
+
response = await fetch(url, {
|
|
1027
|
+
signal: requestSignal,
|
|
1028
|
+
method: 'POST',
|
|
1029
|
+
headers: {
|
|
1030
|
+
'content-type': 'application/json',
|
|
1031
|
+
'x-deepline-internal-token': internalToken,
|
|
1032
|
+
'x-deepline-runtime-trace-id': traceId,
|
|
1033
|
+
...vercelProtectionHeaders,
|
|
1034
|
+
},
|
|
1035
|
+
body: JSON.stringify({
|
|
1036
|
+
runId: input.launch.runId,
|
|
1037
|
+
attempt: input.attempt,
|
|
1038
|
+
leaseToken: input.leaseToken,
|
|
1039
|
+
schedulerSchema: input.schedulerSchema ?? null,
|
|
1040
|
+
// Expand-before-enable rollout fence. An old worker never sends this,
|
|
1041
|
+
// so a newly deployed app cannot activate direct terminal reduction
|
|
1042
|
+
// before the owning Fly lane has the cleanup controller.
|
|
1043
|
+
terminalReducerContract: DIRECT_COMPLETED_TERMINAL_CONTRACT,
|
|
1044
|
+
}),
|
|
1045
|
+
redirect: 'manual',
|
|
1046
|
+
});
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1049
|
+
console.warn('[runtime-attempt-token.request_failed]', {
|
|
988
1050
|
runId: input.launch.runId,
|
|
989
1051
|
attempt: input.attempt,
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1052
|
+
traceId,
|
|
1053
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
1054
|
+
status: null,
|
|
1055
|
+
code: 'TRANSPORT',
|
|
1056
|
+
error: message,
|
|
1057
|
+
});
|
|
1058
|
+
throw new AttemptExecutorTokenUnavailableError(
|
|
1059
|
+
`Attempt executor token mint failed [TRANSPORT] at ${new URL(url).host}: ${message}`,
|
|
1060
|
+
null,
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
999
1063
|
const responseForFingerprint = response.clone();
|
|
1000
1064
|
const body = (await response.json().catch(() => null)) as {
|
|
1001
1065
|
executorToken?: unknown;
|
|
@@ -1038,9 +1102,11 @@ export async function requestAttemptExecutorToken(input: {
|
|
|
1038
1102
|
response.status,
|
|
1039
1103
|
);
|
|
1040
1104
|
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1105
|
+
const failure = `Attempt executor token mint failed [${code}] (${response.status}) at ${new URL(url).host}: ${typeof body?.error === 'string' ? body.error : 'invalid response'}`;
|
|
1106
|
+
if (isTransientExecutorTokenStatus(response.status, body !== null)) {
|
|
1107
|
+
throw new AttemptExecutorTokenUnavailableError(failure, response.status);
|
|
1108
|
+
}
|
|
1109
|
+
throw new Error(failure);
|
|
1044
1110
|
}
|
|
1045
1111
|
console.info('[runtime-attempt-token.request_succeeded]', {
|
|
1046
1112
|
runId: input.launch.runId,
|
|
@@ -1857,6 +1923,24 @@ export async function reconcileTerminalTruthIntoReporter(input: {
|
|
|
1857
1923
|
});
|
|
1858
1924
|
}
|
|
1859
1925
|
|
|
1926
|
+
const outcomes = await readTerminalSheetRowOutcomes(input);
|
|
1927
|
+
if (outcomes.length > 0) {
|
|
1928
|
+
input.reporter.applyTerminalSheetOutcomes(outcomes);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/**
|
|
1933
|
+
* Each runtime sheet's settled row split for this run — the data plane's
|
|
1934
|
+
* account of the run's rows (ADR 0016 rule 3). Best-effort per table: a table
|
|
1935
|
+
* whose sheet never materialized has no counts and is skipped loudly.
|
|
1936
|
+
*/
|
|
1937
|
+
async function readTerminalSheetRowOutcomes(input: {
|
|
1938
|
+
launch: PlaySchedulerSubmitInput;
|
|
1939
|
+
runId: string;
|
|
1940
|
+
attempt: number;
|
|
1941
|
+
}): Promise<
|
|
1942
|
+
Array<{ tableNamespace: string; completed: number; failed: number; total: number }>
|
|
1943
|
+
> {
|
|
1860
1944
|
const sheetNamespaces = [
|
|
1861
1945
|
...new Set(
|
|
1862
1946
|
(input.launch.preloadedDbSessions ?? [])
|
|
@@ -1866,7 +1950,7 @@ export async function reconcileTerminalTruthIntoReporter(input: {
|
|
|
1866
1950
|
),
|
|
1867
1951
|
];
|
|
1868
1952
|
if (sheetNamespaces.length === 0) {
|
|
1869
|
-
return;
|
|
1953
|
+
return [];
|
|
1870
1954
|
}
|
|
1871
1955
|
const runtimeContext = {
|
|
1872
1956
|
baseUrl: input.launch.baseUrl,
|
|
@@ -1907,8 +1991,35 @@ export async function reconcileTerminalTruthIntoReporter(input: {
|
|
|
1907
1991
|
});
|
|
1908
1992
|
}
|
|
1909
1993
|
}
|
|
1910
|
-
|
|
1911
|
-
|
|
1994
|
+
return outcomes;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* A failed runner result's settled map counts, read from the runtime sheets
|
|
1999
|
+
* BEFORE the terminal write so they ride the `run.failed` outbox row ahead of
|
|
2000
|
+
* the terminal fact (`buildFailedTerminalSettledProgressEvents`). Never
|
|
2001
|
+
* blocks the terminal: a read that fails leaves the ledger at its last live
|
|
2002
|
+
* counts, as before.
|
|
2003
|
+
*/
|
|
2004
|
+
async function collectFailedTerminalSettledProgressEvents(input: {
|
|
2005
|
+
launch: PlaySchedulerSubmitInput;
|
|
2006
|
+
runId: string;
|
|
2007
|
+
attempt: number;
|
|
2008
|
+
}): Promise<PlayRunLedgerEvent[]> {
|
|
2009
|
+
try {
|
|
2010
|
+
const outcomes = await readTerminalSheetRowOutcomes(input);
|
|
2011
|
+
return buildFailedTerminalSettledProgressEvents({
|
|
2012
|
+
launch: input.launch,
|
|
2013
|
+
outcomes,
|
|
2014
|
+
occurredAt: Date.now(),
|
|
2015
|
+
});
|
|
2016
|
+
} catch (error) {
|
|
2017
|
+
console.warn('[absurd-runtime.worker] failed_terminal_settled_counts_failed', {
|
|
2018
|
+
runId: input.runId,
|
|
2019
|
+
attempt: input.attempt,
|
|
2020
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2021
|
+
});
|
|
2022
|
+
return [];
|
|
1912
2023
|
}
|
|
1913
2024
|
}
|
|
1914
2025
|
type ObservedTerminalSource =
|
|
@@ -1931,11 +2042,48 @@ function requestTerminalOutboxProjection(input: {
|
|
|
1931
2042
|
launch: PlaySchedulerSubmitInput;
|
|
1932
2043
|
runId: string;
|
|
1933
2044
|
options: Pick<PostgresSchedulerOptions, 'schema'>;
|
|
2045
|
+
/** Where the request came from, for terminal-visibility attribution. */
|
|
2046
|
+
trigger?: string;
|
|
2047
|
+
/** When this worker began post-commit projection for the terminal. */
|
|
2048
|
+
projectionStartedAtMs?: number;
|
|
1934
2049
|
}): void {
|
|
1935
2050
|
const targetEventId = `run-terminal:${input.runId}`;
|
|
2051
|
+
const requestedAtMs = Date.now();
|
|
2052
|
+
// Terminal-visibility evidence: the request itself, and how long the
|
|
2053
|
+
// post-commit work before it took, so a slow first claim is attributable.
|
|
2054
|
+
console.info('[run-ledger-outbox.immediate_dispatch_requested]', {
|
|
2055
|
+
runId: input.runId,
|
|
2056
|
+
trigger: input.trigger ?? 'unspecified',
|
|
2057
|
+
requestedAtMs,
|
|
2058
|
+
sinceProjectionStartMs:
|
|
2059
|
+
input.projectionStartedAtMs === undefined
|
|
2060
|
+
? null
|
|
2061
|
+
: requestedAtMs - input.projectionStartedAtMs,
|
|
2062
|
+
});
|
|
1936
2063
|
void input.deps
|
|
1937
2064
|
.projectOutbox(input.launch, input.runId, input.options, targetEventId)
|
|
1938
2065
|
.then((projection) => {
|
|
2066
|
+
// Terminal-visibility latency evidence (ADR-0022 budget): when this
|
|
2067
|
+
// worker asked for delivery and how long the delivery took.
|
|
2068
|
+
console.info('[run-ledger-outbox.immediate_dispatch_settled]', {
|
|
2069
|
+
runId: input.runId,
|
|
2070
|
+
requestedAtMs,
|
|
2071
|
+
durationMs: Date.now() - requestedAtMs,
|
|
2072
|
+
targetDelivered: projection?.targetDelivered ?? null,
|
|
2073
|
+
attempted: projection?.attempted ?? null,
|
|
2074
|
+
projected: projection?.projected ?? null,
|
|
2075
|
+
});
|
|
2076
|
+
if (projection && projection.attempted === 0 && !projection.targetDelivered) {
|
|
2077
|
+
// Nothing was claimable for this run: another dispatcher holds its
|
|
2078
|
+
// outbox lease or a predecessor is not yet due. Delivery now depends
|
|
2079
|
+
// on that holder or the projector's next wave.
|
|
2080
|
+
console.info('[run-ledger-outbox.immediate_dispatch_empty_claim]', {
|
|
2081
|
+
runId: input.runId,
|
|
2082
|
+
targetEventId,
|
|
2083
|
+
requestedAtMs,
|
|
2084
|
+
durationMs: Date.now() - requestedAtMs,
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
1939
2087
|
// An empty drain means another concurrent projector already claimed and
|
|
1940
2088
|
// delivered the terminal fact. It is not pending work and warning here
|
|
1941
2089
|
// turns the normal exactly-once race into misleading operator noise.
|
|
@@ -2081,6 +2229,12 @@ async function settleObservedTerminal(input: {
|
|
|
2081
2229
|
)
|
|
2082
2230
|
)?.status;
|
|
2083
2231
|
if (status !== 'completed' && status !== 'failed' && status !== 'cancelled') {
|
|
2232
|
+
console.info('[run-ledger-outbox.immediate_dispatch_skipped]', {
|
|
2233
|
+
runId: input.runId,
|
|
2234
|
+
source: input.source,
|
|
2235
|
+
reason: 'run_not_terminal',
|
|
2236
|
+
status: status ?? null,
|
|
2237
|
+
});
|
|
2084
2238
|
return;
|
|
2085
2239
|
}
|
|
2086
2240
|
const backfilledBilling = await input.deps.runWithClient((client) =>
|
|
@@ -2100,7 +2254,7 @@ async function settleObservedTerminal(input: {
|
|
|
2100
2254
|
},
|
|
2101
2255
|
);
|
|
2102
2256
|
}
|
|
2103
|
-
requestTerminalOutboxProjection(input);
|
|
2257
|
+
requestTerminalOutboxProjection({ ...input, trigger: input.source });
|
|
2104
2258
|
}
|
|
2105
2259
|
|
|
2106
2260
|
/**
|
|
@@ -2516,16 +2670,66 @@ async function executeAbsurdAttemptAdapter(
|
|
|
2516
2670
|
cleanupAuthorized: false,
|
|
2517
2671
|
}),
|
|
2518
2672
|
);
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2673
|
+
// A failed handoff never destroys the live runner (no cleanup is
|
|
2674
|
+
// authorized here) and does not terminalize a Run whose runner may still
|
|
2675
|
+
// finish (#6781). The wait stays bounded by wall-clock time: an
|
|
2676
|
+
// executor-token outage defers only until the detached runner's hard
|
|
2677
|
+
// ceiling; past it the Run is terminalized exactly once under its fence,
|
|
2678
|
+
// and runner cleanup follows only from that recorded terminal outcome.
|
|
2679
|
+
if (!(error instanceof AttemptExecutorTokenUnavailableError)) {
|
|
2680
|
+
throw new DeferTask(
|
|
2681
|
+
`Detached runner ${runId}/${generation} retains execution after scheduler handoff failure.`,
|
|
2682
|
+
1,
|
|
2683
|
+
);
|
|
2684
|
+
}
|
|
2685
|
+
// Throws DeferTask while inside the wall-clock bound.
|
|
2686
|
+
await deferAbsurdAttemptForExecutorTokenOutage({
|
|
2687
|
+
deps,
|
|
2688
|
+
options,
|
|
2689
|
+
runId,
|
|
2690
|
+
generation,
|
|
2691
|
+
leaseToken,
|
|
2692
|
+
detachedRecovery: true,
|
|
2693
|
+
detachedSuspension: detached.suspension,
|
|
2694
|
+
error,
|
|
2695
|
+
});
|
|
2696
|
+
await salvageAbsurdFinalAttemptTerminal({
|
|
2697
|
+
ctx,
|
|
2698
|
+
deps,
|
|
2699
|
+
options,
|
|
2700
|
+
runId,
|
|
2701
|
+
launch: executionLaunch,
|
|
2702
|
+
generation,
|
|
2703
|
+
leaseToken,
|
|
2704
|
+
error,
|
|
2705
|
+
detachedRecovery: true,
|
|
2706
|
+
force: true,
|
|
2707
|
+
});
|
|
2708
|
+
throw error;
|
|
2523
2709
|
}
|
|
2524
2710
|
if (error instanceof CancelledTask || error instanceof FailedTask) {
|
|
2525
2711
|
// Park/cancel signals belong to the engine; never treat them as leg
|
|
2526
2712
|
// failures.
|
|
2527
2713
|
throw error;
|
|
2528
2714
|
}
|
|
2715
|
+
let forceTerminalSalvage = false;
|
|
2716
|
+
if (error instanceof AttemptExecutorTokenUnavailableError) {
|
|
2717
|
+
// Throws DeferTask while inside the wall-clock bound; returns only when
|
|
2718
|
+
// the bound is exhausted and this attempt must write the terminal.
|
|
2719
|
+
await deferAbsurdAttemptForExecutorTokenOutage({
|
|
2720
|
+
deps,
|
|
2721
|
+
options,
|
|
2722
|
+
runId,
|
|
2723
|
+
generation,
|
|
2724
|
+
leaseToken,
|
|
2725
|
+
detachedRecovery: isDetachedRecovery,
|
|
2726
|
+
detachedSuspension: isDetachedRecovery
|
|
2727
|
+
? (assigned.suspension ?? null)
|
|
2728
|
+
: null,
|
|
2729
|
+
error,
|
|
2730
|
+
});
|
|
2731
|
+
forceTerminalSalvage = true;
|
|
2732
|
+
}
|
|
2529
2733
|
// Token minting and other pre-run setup can fail after a sandbox has
|
|
2530
2734
|
// already been recorded. There is no runner config available on this
|
|
2531
2735
|
// outer path, so persist the controller-owned cleanup obligation here.
|
|
@@ -2577,6 +2781,8 @@ async function executeAbsurdAttemptAdapter(
|
|
|
2577
2781
|
generation,
|
|
2578
2782
|
leaseToken,
|
|
2579
2783
|
error,
|
|
2784
|
+
detachedRecovery: isDetachedRecovery,
|
|
2785
|
+
force: forceTerminalSalvage,
|
|
2580
2786
|
});
|
|
2581
2787
|
throw error;
|
|
2582
2788
|
} finally {
|
|
@@ -2781,6 +2987,16 @@ async function executeAssignedAbsurdAttempt(input: {
|
|
|
2781
2987
|
});
|
|
2782
2988
|
}
|
|
2783
2989
|
|
|
2990
|
+
// A failed run's settled counts must reach the ledger BEFORE its terminal
|
|
2991
|
+
// makes the steps immutable, so they ride the terminal outbox row.
|
|
2992
|
+
const failedTerminalProgressEvents =
|
|
2993
|
+
runnerResult.status === 'failed'
|
|
2994
|
+
? await collectFailedTerminalSettledProgressEvents({
|
|
2995
|
+
launch,
|
|
2996
|
+
runId,
|
|
2997
|
+
attempt: generation,
|
|
2998
|
+
})
|
|
2999
|
+
: [];
|
|
2784
3000
|
const result = await deps.runWithClient((client) =>
|
|
2785
3001
|
executePostgresSchedulerClaim(
|
|
2786
3002
|
client,
|
|
@@ -2790,6 +3006,9 @@ async function executeAssignedAbsurdAttempt(input: {
|
|
|
2790
3006
|
...options,
|
|
2791
3007
|
terminalLogOutbox: input.fromDetachedRunner ? 'ctx_log_tail' : 'omit',
|
|
2792
3008
|
sandboxCrashDiagnostic,
|
|
3009
|
+
...(failedTerminalProgressEvents.length > 0
|
|
3010
|
+
? { failedTerminalProgressEvents }
|
|
3011
|
+
: {}),
|
|
2793
3012
|
},
|
|
2794
3013
|
),
|
|
2795
3014
|
);
|
|
@@ -2802,6 +3021,13 @@ async function executeAssignedAbsurdAttempt(input: {
|
|
|
2802
3021
|
`absurd runtime worker: unexpected non-terminal claim status "${result.status}" for ${runId}.`,
|
|
2803
3022
|
);
|
|
2804
3023
|
}
|
|
3024
|
+
console.info('[absurd-runtime.worker] terminal_committed', {
|
|
3025
|
+
runId,
|
|
3026
|
+
generation,
|
|
3027
|
+
status: result.status,
|
|
3028
|
+
fromDetachedRunner: input.fromDetachedRunner,
|
|
3029
|
+
committedAtMs: Date.now(),
|
|
3030
|
+
});
|
|
2805
3031
|
|
|
2806
3032
|
await terminalProjection.projectCommitted({
|
|
2807
3033
|
runnerResult: terminalEnvelopeFromRunnerResult(runnerResult),
|
|
@@ -4149,6 +4375,7 @@ function createAbsurdWorkerTerminalProjectionAdapter(input: {
|
|
|
4149
4375
|
);
|
|
4150
4376
|
});
|
|
4151
4377
|
};
|
|
4378
|
+
let projectionStartedAtMs: number | undefined;
|
|
4152
4379
|
return createPlayRunAttemptWorkerTerminalProjection({
|
|
4153
4380
|
async observeFenceLost() {
|
|
4154
4381
|
// A lost fence means this attempt is dead, which a cancelled run is the
|
|
@@ -4164,6 +4391,7 @@ function createAbsurdWorkerTerminalProjectionAdapter(input: {
|
|
|
4164
4391
|
});
|
|
4165
4392
|
},
|
|
4166
4393
|
async reconcileTerminalTruth() {
|
|
4394
|
+
projectionStartedAtMs ??= Date.now();
|
|
4167
4395
|
// ADR 0016 rule 3: detached Daytona runs may provide no live events, so
|
|
4168
4396
|
// fold stored docflow/runtime-sheet facts into progress after truth wins.
|
|
4169
4397
|
await reconcileTerminalTruthIntoReporter({
|
|
@@ -4247,6 +4475,10 @@ function createAbsurdWorkerTerminalProjectionAdapter(input: {
|
|
|
4247
4475
|
launch: input.launch,
|
|
4248
4476
|
runId: input.runId,
|
|
4249
4477
|
options: input.options,
|
|
4478
|
+
trigger: 'committed_terminal',
|
|
4479
|
+
...(projectionStartedAtMs === undefined
|
|
4480
|
+
? {}
|
|
4481
|
+
: { projectionStartedAtMs }),
|
|
4250
4482
|
});
|
|
4251
4483
|
},
|
|
4252
4484
|
});
|
|
@@ -4960,6 +5192,111 @@ async function releaseAbsurdAttemptRuntimeState(input: {
|
|
|
4960
5192
|
}
|
|
4961
5193
|
}
|
|
4962
5194
|
|
|
5195
|
+
/**
|
|
5196
|
+
* An executor-token outage happens before any Play code runs under this
|
|
5197
|
+
* attempt, so it must not spend an engine attempt: a few minutes of app
|
|
5198
|
+
* restart would otherwise exhaust max_attempts. Defer the durable task while
|
|
5199
|
+
* inside a wall-clock bound, then return so the caller terminalizes loudly.
|
|
5200
|
+
*
|
|
5201
|
+
* - Fresh attempt: retire it and requeue the run (like a capacity deferral);
|
|
5202
|
+
* the bound is the start of the unbroken outage recorded on attempt rows.
|
|
5203
|
+
* - Detached-runner wake: the attempt stays `waiting` under its original
|
|
5204
|
+
* fence, because a live runner may still push its terminal. Nothing is
|
|
5205
|
+
* written; the bound is the runner's persisted hard ceiling.
|
|
5206
|
+
*/
|
|
5207
|
+
async function deferAbsurdAttemptForExecutorTokenOutage(input: {
|
|
5208
|
+
deps: AbsurdRuntimeWorkerDeps;
|
|
5209
|
+
options: Pick<PostgresSchedulerOptions, 'schema'>;
|
|
5210
|
+
runId: string;
|
|
5211
|
+
generation: number;
|
|
5212
|
+
leaseToken: string;
|
|
5213
|
+
detachedRecovery: boolean;
|
|
5214
|
+
detachedSuspension: unknown;
|
|
5215
|
+
error: AttemptExecutorTokenUnavailableError;
|
|
5216
|
+
}): Promise<void> {
|
|
5217
|
+
const nowMs = Date.now();
|
|
5218
|
+
if (input.detachedRecovery) {
|
|
5219
|
+
const deadlineMs = detachedRunnerCeilingDeadlineMsFromSuspension(
|
|
5220
|
+
input.detachedSuspension,
|
|
5221
|
+
);
|
|
5222
|
+
if (deadlineMs !== null && nowMs < deadlineMs) {
|
|
5223
|
+
const delaySeconds = Math.max(
|
|
5224
|
+
1,
|
|
5225
|
+
Math.min(
|
|
5226
|
+
ABSURD_EXECUTOR_TOKEN_OUTAGE_DEFER_SECONDS,
|
|
5227
|
+
Math.ceil((deadlineMs - nowMs) / 1_000),
|
|
5228
|
+
),
|
|
5229
|
+
);
|
|
5230
|
+
console.warn('[absurd-runtime.worker] executor_token_outage_deferred', {
|
|
5231
|
+
runId: input.runId,
|
|
5232
|
+
generation: input.generation,
|
|
5233
|
+
detachedRecovery: true,
|
|
5234
|
+
status: input.error.status,
|
|
5235
|
+
deadlineMs,
|
|
5236
|
+
delaySeconds,
|
|
5237
|
+
});
|
|
5238
|
+
throw new DeferTask(input.error.message, delaySeconds);
|
|
5239
|
+
}
|
|
5240
|
+
console.error(
|
|
5241
|
+
'[absurd-runtime.worker] executor_token_outage_bound_exhausted',
|
|
5242
|
+
{
|
|
5243
|
+
runId: input.runId,
|
|
5244
|
+
generation: input.generation,
|
|
5245
|
+
detachedRecovery: true,
|
|
5246
|
+
status: input.error.status,
|
|
5247
|
+
deadlineMs,
|
|
5248
|
+
},
|
|
5249
|
+
);
|
|
5250
|
+
return;
|
|
5251
|
+
}
|
|
5252
|
+
// The retired attempt may already own a recorded sandbox; persist its
|
|
5253
|
+
// cleanup obligation before the attempt leaves the running fence.
|
|
5254
|
+
await persistAttemptSandboxCleanupBeforeBestEffortGc({
|
|
5255
|
+
deps: input.deps,
|
|
5256
|
+
runId: input.runId,
|
|
5257
|
+
attempt: input.generation,
|
|
5258
|
+
options: input.options,
|
|
5259
|
+
});
|
|
5260
|
+
const outcome = await input.deps.runWithClient((client) =>
|
|
5261
|
+
deferPostgresSchedulerAttemptForExecutorTokenOutage(
|
|
5262
|
+
client,
|
|
5263
|
+
{
|
|
5264
|
+
runId: input.runId,
|
|
5265
|
+
attempt: input.generation,
|
|
5266
|
+
leaseToken: input.leaseToken,
|
|
5267
|
+
reason: input.error.message,
|
|
5268
|
+
maxOutageMs: ABSURD_EXECUTOR_TOKEN_OUTAGE_MAX_MS,
|
|
5269
|
+
},
|
|
5270
|
+
input.options,
|
|
5271
|
+
),
|
|
5272
|
+
);
|
|
5273
|
+
if (outcome.deferred) {
|
|
5274
|
+
console.warn('[absurd-runtime.worker] executor_token_outage_deferred', {
|
|
5275
|
+
runId: input.runId,
|
|
5276
|
+
generation: input.generation,
|
|
5277
|
+
detachedRecovery: false,
|
|
5278
|
+
status: input.error.status,
|
|
5279
|
+
outageStartedAtMs: outcome.outageStartedAtMs,
|
|
5280
|
+
delaySeconds: ABSURD_EXECUTOR_TOKEN_OUTAGE_DEFER_SECONDS,
|
|
5281
|
+
});
|
|
5282
|
+
throw new DeferTask(
|
|
5283
|
+
input.error.message,
|
|
5284
|
+
ABSURD_EXECUTOR_TOKEN_OUTAGE_DEFER_SECONDS,
|
|
5285
|
+
);
|
|
5286
|
+
}
|
|
5287
|
+
console.error(
|
|
5288
|
+
'[absurd-runtime.worker] executor_token_outage_bound_exhausted',
|
|
5289
|
+
{
|
|
5290
|
+
runId: input.runId,
|
|
5291
|
+
generation: input.generation,
|
|
5292
|
+
detachedRecovery: false,
|
|
5293
|
+
status: input.error.status,
|
|
5294
|
+
outageStartedAtMs: outcome.outageStartedAtMs,
|
|
5295
|
+
maxOutageMs: ABSURD_EXECUTOR_TOKEN_OUTAGE_MAX_MS,
|
|
5296
|
+
},
|
|
5297
|
+
);
|
|
5298
|
+
}
|
|
5299
|
+
|
|
4963
5300
|
/**
|
|
4964
5301
|
* Final-attempt terminal salvage. When the LAST engine attempt of a play-run
|
|
4965
5302
|
* task dies with an unexpected error (Daytona/app-runtime/DB throw — NOT a park
|
|
@@ -4984,6 +5321,12 @@ async function salvageAbsurdFinalAttemptTerminal(input: {
|
|
|
4984
5321
|
generation: number;
|
|
4985
5322
|
leaseToken: string;
|
|
4986
5323
|
error: unknown;
|
|
5324
|
+
/** The attempt was claimed as a parked detached-runner wake and may still
|
|
5325
|
+
* be `waiting` under its original fence. */
|
|
5326
|
+
detachedRecovery?: boolean;
|
|
5327
|
+
/** A wall-clock bound, not the engine attempt count, requires the terminal
|
|
5328
|
+
* now (executor-token outage past its bound). */
|
|
5329
|
+
force?: boolean;
|
|
4987
5330
|
}): Promise<void> {
|
|
4988
5331
|
const attempt = input.ctx.attempt;
|
|
4989
5332
|
const maxAttempts = input.ctx.maxAttempts;
|
|
@@ -5001,12 +5344,15 @@ async function salvageAbsurdFinalAttemptTerminal(input: {
|
|
|
5001
5344
|
{ cause: input.error },
|
|
5002
5345
|
);
|
|
5003
5346
|
}
|
|
5004
|
-
if (attempt < maxAttempts) return;
|
|
5347
|
+
if (!input.force && attempt < maxAttempts) return;
|
|
5005
5348
|
const message =
|
|
5006
5349
|
input.error instanceof Error ? input.error.message : String(input.error);
|
|
5007
5350
|
const failedResult: PlayRunnerResult = {
|
|
5008
5351
|
status: 'failed',
|
|
5009
|
-
error:
|
|
5352
|
+
error:
|
|
5353
|
+
input.force && attempt < maxAttempts
|
|
5354
|
+
? `absurd play-run task exhausted its executor-token outage bound on engine attempt ${attempt}/${maxAttempts}: ${message}`
|
|
5355
|
+
: `absurd play-run task failed on its final engine attempt (${attempt}/${maxAttempts}): ${message}`,
|
|
5010
5356
|
logs: [],
|
|
5011
5357
|
stats: {},
|
|
5012
5358
|
steps: [],
|
|
@@ -5028,14 +5374,33 @@ async function salvageAbsurdFinalAttemptTerminal(input: {
|
|
|
5028
5374
|
salvageAttempt += 1
|
|
5029
5375
|
) {
|
|
5030
5376
|
try {
|
|
5031
|
-
result = await input.deps.runWithClient((client) =>
|
|
5032
|
-
|
|
5377
|
+
result = await input.deps.runWithClient(async (client) => {
|
|
5378
|
+
// A detached-runner wake keeps its attempt `waiting` until setup
|
|
5379
|
+
// succeeds. Take that same fence to `running` (the overdue-ceiling
|
|
5380
|
+
// claim's transition) so the failure terminal can be written. When
|
|
5381
|
+
// the attempt is no longer waiting — it already resumed, or a newer
|
|
5382
|
+
// fence or pushed terminal owns it — the ordinary fenced path below
|
|
5383
|
+
// decides.
|
|
5384
|
+
if (input.detachedRecovery) {
|
|
5385
|
+
await claimPostgresSchedulerWaitingDetachedAttemptForTerminal(
|
|
5386
|
+
client,
|
|
5387
|
+
{
|
|
5388
|
+
runId: input.runId,
|
|
5389
|
+
attempt: input.generation,
|
|
5390
|
+
leaseToken: input.leaseToken,
|
|
5391
|
+
},
|
|
5392
|
+
input.options,
|
|
5393
|
+
);
|
|
5394
|
+
}
|
|
5395
|
+
return await executePostgresSchedulerClaim(
|
|
5033
5396
|
client,
|
|
5034
5397
|
claim,
|
|
5035
5398
|
async () => failedResult,
|
|
5036
|
-
input.
|
|
5037
|
-
|
|
5038
|
-
|
|
5399
|
+
input.detachedRecovery
|
|
5400
|
+
? { ...input.options, preserveRunnerTerminal: true }
|
|
5401
|
+
: input.options,
|
|
5402
|
+
);
|
|
5403
|
+
});
|
|
5039
5404
|
break;
|
|
5040
5405
|
} catch (salvageError) {
|
|
5041
5406
|
try {
|
|
@@ -5720,6 +6085,57 @@ export async function recoverAbsurdRuntimeOverdueDetachedRunnerCeilings(input: {
|
|
|
5720
6085
|
return { recovered, skipped };
|
|
5721
6086
|
}
|
|
5722
6087
|
|
|
6088
|
+
export const ABSURD_OVERDUE_DETACHED_RUNNER_RECOVERY_INTERVAL_MS = 15_000;
|
|
6089
|
+
|
|
6090
|
+
/**
|
|
6091
|
+
* In-process hard-ceiling recovery loop for a topology with no release
|
|
6092
|
+
* scaler (local dev). Production keeps this obligation on the fixed scaler.
|
|
6093
|
+
* A pass that settled anything runs again at once: each claimed run leaves the
|
|
6094
|
+
* `waiting` candidate set in its own statement, so the backlog drains by
|
|
6095
|
+
* construction; an empty pass sleeps one interval.
|
|
6096
|
+
*/
|
|
6097
|
+
export async function runAbsurdOverdueDetachedRunnerCeilingController(input: {
|
|
6098
|
+
runOnce: () => Promise<{ recovered: number; skipped: number }>;
|
|
6099
|
+
shouldStop: () => boolean;
|
|
6100
|
+
intervalMs?: number;
|
|
6101
|
+
sleep?: (ms: number) => Promise<void>;
|
|
6102
|
+
}): Promise<void> {
|
|
6103
|
+
const intervalMs = Math.max(
|
|
6104
|
+
1,
|
|
6105
|
+
Math.floor(
|
|
6106
|
+
input.intervalMs ?? ABSURD_OVERDUE_DETACHED_RUNNER_RECOVERY_INTERVAL_MS,
|
|
6107
|
+
),
|
|
6108
|
+
);
|
|
6109
|
+
const wait = input.sleep ?? ((ms: number) => sleep(ms));
|
|
6110
|
+
const reportFailure = createRateLimitedRuntimeLogger({
|
|
6111
|
+
log: (...args) => console.error(...args),
|
|
6112
|
+
});
|
|
6113
|
+
while (!input.shouldStop()) {
|
|
6114
|
+
let settled = 0;
|
|
6115
|
+
try {
|
|
6116
|
+
const outcome = await input.runOnce();
|
|
6117
|
+
settled = outcome.recovered + outcome.skipped;
|
|
6118
|
+
if (settled > 0) {
|
|
6119
|
+
console.info(
|
|
6120
|
+
'[absurd-runtime.worker] overdue_detached_runner_recovery',
|
|
6121
|
+
outcome,
|
|
6122
|
+
);
|
|
6123
|
+
}
|
|
6124
|
+
} catch (error) {
|
|
6125
|
+
reportFailure(
|
|
6126
|
+
'[absurd-runtime.worker] overdue_detached_runner_recovery_failed',
|
|
6127
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
6128
|
+
);
|
|
6129
|
+
}
|
|
6130
|
+
if (settled > 0) continue;
|
|
6131
|
+
// Sleep in short slices so host shutdown is not held for a full interval.
|
|
6132
|
+
const deadline = Date.now() + intervalMs;
|
|
6133
|
+
while (!input.shouldStop() && Date.now() < deadline) {
|
|
6134
|
+
await wait(Math.min(500, Math.max(1, deadline - Date.now())));
|
|
6135
|
+
}
|
|
6136
|
+
}
|
|
6137
|
+
}
|
|
6138
|
+
|
|
5723
6139
|
/**
|
|
5724
6140
|
* Fixed-controller sweep across every persisted release, including lanes no
|
|
5725
6141
|
* longer present in the live release registry. The run's immutable launch
|
|
@@ -7427,6 +7843,12 @@ export type RuntimeWorkerHostConfig = {
|
|
|
7427
7843
|
/** Invoked once the lane has drained (drain mode only). The entrypoint wires
|
|
7428
7844
|
* this to a clean `process.exit(0)`. */
|
|
7429
7845
|
onDrained?: () => void;
|
|
7846
|
+
/**
|
|
7847
|
+
* Run the detached-runner hard-ceiling recovery in this process. Only for a
|
|
7848
|
+
* topology without the fixed release scaler (the local dev supervisor);
|
|
7849
|
+
* production leaves it to the scaler. Default off.
|
|
7850
|
+
*/
|
|
7851
|
+
recoverOverdueDetachedRunnerCeilings?: boolean;
|
|
7430
7852
|
};
|
|
7431
7853
|
|
|
7432
7854
|
export type RuntimeWorkerHost = {
|
|
@@ -7675,6 +8097,19 @@ export async function startRuntimeWorkerHost(
|
|
|
7675
8097
|
readSandboxCrashDiagnostic: workerDeps.readSandboxCrashDiagnostic,
|
|
7676
8098
|
shouldStop: () => cleanupStopped,
|
|
7677
8099
|
});
|
|
8100
|
+
let overdueCeilingStopped = false;
|
|
8101
|
+
const overdueCeilingController = options.recoverOverdueDetachedRunnerCeilings
|
|
8102
|
+
? runAbsurdOverdueDetachedRunnerCeilingController({
|
|
8103
|
+
runOnce: () =>
|
|
8104
|
+
withRuntimeSchedulerClient(poolOptions, (client) =>
|
|
8105
|
+
recoverAbsurdRuntimeOverdueDetachedRunnerCeilingsAcrossAllLanes({
|
|
8106
|
+
client,
|
|
8107
|
+
schedulerOptions,
|
|
8108
|
+
}),
|
|
8109
|
+
),
|
|
8110
|
+
shouldStop: () => overdueCeilingStopped,
|
|
8111
|
+
})
|
|
8112
|
+
: null;
|
|
7678
8113
|
// Drain self-exit (old-lane retirement). Off unless the deploy system opts in.
|
|
7679
8114
|
let drainStopped = false;
|
|
7680
8115
|
let drainWatch: Promise<'drained' | 'stopped'> | null = null;
|
|
@@ -7828,6 +8263,8 @@ export async function startRuntimeWorkerHost(
|
|
|
7828
8263
|
releaseId,
|
|
7829
8264
|
drainExit,
|
|
7830
8265
|
laneAdoption: !drainExit,
|
|
8266
|
+
overdueDetachedRunnerRecovery:
|
|
8267
|
+
options.recoverOverdueDetachedRunnerCeilings === true,
|
|
7831
8268
|
workerName: workerBase,
|
|
7832
8269
|
});
|
|
7833
8270
|
|
|
@@ -7841,8 +8278,10 @@ export async function startRuntimeWorkerHost(
|
|
|
7841
8278
|
billingStopped = true;
|
|
7842
8279
|
cleanupStopped = true;
|
|
7843
8280
|
adoptionStopped = true;
|
|
8281
|
+
overdueCeilingStopped = true;
|
|
7844
8282
|
},
|
|
7845
8283
|
controllers: [
|
|
8284
|
+
overdueCeilingController,
|
|
7846
8285
|
drainWatch,
|
|
7847
8286
|
adoptionSupervisor,
|
|
7848
8287
|
billingController,
|