c8ctl-plugin-nano 1.61.2 → 1.62.0
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 +7 -4
- package/agent-instance.mjs +44 -0
- package/agentic.mjs +4 -2
- package/c8ctl-plugin.js +421 -73
- package/package.json +8 -8
- package/supervisor.dist.js +4 -4
package/c8ctl-plugin.js
CHANGED
|
@@ -3089,84 +3089,191 @@ function resolveAutoRestConfig(camunda, env = process.env) {
|
|
|
3089
3089
|
// exist" is answerable from that engine alone.
|
|
3090
3090
|
//
|
|
3091
3091
|
// `@nanobpm/agentic/demand` already reads deployed `taskDefinition` leaves over
|
|
3092
|
-
// C8 REST (`process-definitions/search` → `/{key}/xml`)
|
|
3093
|
-
//
|
|
3094
|
-
//
|
|
3095
|
-
// `<zeebe:
|
|
3096
|
-
// `
|
|
3097
|
-
//
|
|
3098
|
-
//
|
|
3099
|
-
// `linkName="prompt"` linked resource, and no in-process worker task does. Not
|
|
3100
|
-
// every service task is an agent task — plain connectors and record-keepers
|
|
3101
|
-
// (e.g. `pr.record-plan`) are ordinary workers, and they carry no prompt link.
|
|
3092
|
+
// C8 REST (`process-definitions/search` → `/{key}/xml`): `scanTaskDefinitions(xml)`
|
|
3093
|
+
// yields every service-task leaf with its `taskType`/`process`/`elementId`. Since
|
|
3094
|
+
// issue #235 the SINGLE auto-discovery convention is the external-agent marker
|
|
3095
|
+
// `<zeebe:agentDefinition agentType="external">` (see below), not the earlier
|
|
3096
|
+
// linked-prompt `agentic` flag or the legacy `io.nanobpm.agentTask` header. Not
|
|
3097
|
+
// every service task is an agent task — plain connectors and record-keepers (e.g.
|
|
3098
|
+
// `pr.record-plan`) carry no marker and are ordinary workers.
|
|
3102
3099
|
//
|
|
3103
|
-
// Per AGENTS.md "Derivation Over Duplication: No Drift Surfaces",
|
|
3104
|
-
//
|
|
3105
|
-
//
|
|
3106
|
-
//
|
|
3107
|
-
//
|
|
3108
|
-
//
|
|
3109
|
-
// dot-grammar.
|
|
3100
|
+
// Per AGENTS.md "Derivation Over Duplication: No Drift Surfaces", the authoritative
|
|
3101
|
+
// task-type / process derivation still comes from `scanTaskDefinitions`; the plugin
|
|
3102
|
+
// only supplements the "is this an external agent task?" answer locally until the
|
|
3103
|
+
// package exposes an `external` leaf flag (`scanAgentTaskLeaves` prefers it when
|
|
3104
|
+
// present). Advertise the raw job-type string the engine matches (`senior:plan`)
|
|
3105
|
+
// verbatim — colon-named types are NOT forced through the agentic dot-grammar.
|
|
3110
3106
|
// ---------------------------------------------------------------------------
|
|
3111
3107
|
|
|
3112
|
-
//
|
|
3113
|
-
//
|
|
3114
|
-
// `linkName="prompt"` linked resource. The current package detector
|
|
3115
|
-
// (`scanTaskDefinitions`, whose `agentic` flag keys SOLELY off the linked-prompt
|
|
3116
|
-
// side-car) therefore reports `agentic:false` for such tasks. We keep a narrow,
|
|
3117
|
-
// self-contained fallback so `--auto` still discovers agent job types against an
|
|
3118
|
-
// engine still holding a pre-#203 deployment (issue #120 acceptance criterion:
|
|
3119
|
-
// "Legacy header-based BPMN still discovers correctly"). This is a supplement to
|
|
3120
|
-
// — never a replacement for — the package flag: the authoritative task-type /
|
|
3121
|
-
// process derivation still comes from `scanTaskDefinitions`; this only answers
|
|
3122
|
-
// "is this leaf an agent task?" for the legacy shape.
|
|
3108
|
+
// Single-convention agent-task marker (issue #235): every external agent task
|
|
3109
|
+
// carries the ONE canonical eligibility flag
|
|
3123
3110
|
//
|
|
3124
|
-
//
|
|
3125
|
-
//
|
|
3126
|
-
//
|
|
3127
|
-
//
|
|
3128
|
-
//
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
)
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
//
|
|
3138
|
-
//
|
|
3139
|
-
//
|
|
3140
|
-
//
|
|
3141
|
-
//
|
|
3142
|
-
|
|
3111
|
+
// <zeebe:agentDefinition agentType="external" />
|
|
3112
|
+
//
|
|
3113
|
+
// inside its `extensionElements` (nano-workforce enforces it via
|
|
3114
|
+
// `agent-marker.test.ts`; registered in its `app/contracts.ts`). This replaces the
|
|
3115
|
+
// former dual `--auto` detection — the `@nanobpm/agentic` linked-prompt `agentic`
|
|
3116
|
+
// flag OR the pre-#203 `io.nanobpm.agentTask` `<zeebe:header>` fallback — with the
|
|
3117
|
+
// single marker every external agent task already declares. Behaviour change: a
|
|
3118
|
+
// prompt-bearing task WITHOUT the external marker is no longer auto-discovered
|
|
3119
|
+
// (safe for nano-workforce, where every `senior:*` task carries the marker).
|
|
3120
|
+
//
|
|
3121
|
+
// Preferred long-term derivation is a package-supplied `external` flag on each
|
|
3122
|
+
// `scanTaskDefinitions` leaf ("derivation over duplication"); until the package
|
|
3123
|
+
// exposes it, this self-contained supplement scan answers "is this leaf an
|
|
3124
|
+
// external agent task?" and `scanAgentTaskLeaves` prefers `leaf.external` when
|
|
3125
|
+
// present, falling back to it. The authoritative task-type / process derivation
|
|
3126
|
+
// still comes from `scanTaskDefinitions`.
|
|
3127
|
+
//
|
|
3128
|
+
// True when `body` (a service task's inner XML) declares a
|
|
3129
|
+
// `<zeebe:agentDefinition agentType="external">` element (attribute order and
|
|
3130
|
+
// quote style tolerated). Compiled once — called per matched `<serviceTask>`
|
|
3131
|
+
// during `--auto` scans, so recompiling per call would allocate needlessly.
|
|
3132
|
+
// CASE-SENSITIVE by design: XML attribute values are case-sensitive and the
|
|
3133
|
+
// convention specifies the literal `external`, so `agentType="External"`/`"EXTERNAL"`
|
|
3134
|
+
// is NOT the canonical marker and must not auto-enrol a non-conforming task.
|
|
3135
|
+
// Boundaries are anchored on XML whitespace / tag termination, NOT `\b`
|
|
3136
|
+
// (a word boundary): `agentDefinition\b` would also match a foreign element
|
|
3137
|
+
// like `<zeebe:agentDefinition-extra …>`. Require `[\s/>]` after the element
|
|
3138
|
+
// name so only the canonical marker enrols a task. Attribute matching is
|
|
3139
|
+
// QUOTE-AWARE (`parseXmlAttrs`, not a raw substring scan): a naive
|
|
3140
|
+
// `\sagentType\s*=\s*"external"` would also fire on `agentType='external'`
|
|
3141
|
+
// nested inside ANOTHER attribute's quoted value (e.g.
|
|
3142
|
+
// `description="text agentType='external'"`), auto-enrolling a non-conforming
|
|
3143
|
+
// task; parsing the element's attributes left-to-right and reading the real
|
|
3144
|
+
// unqualified `agentType` avoids that (and naturally rejects a prefixed
|
|
3145
|
+
// `other:agentType`). Compiled once — called per matched `<serviceTask>` during
|
|
3146
|
+
// `--auto` scans, so recompiling per call would allocate needlessly.
|
|
3147
|
+
const AGENT_DEFINITION_RE = /<(?:\w+:)?agentDefinition(?=[\s/>])([^>]*)>/g;
|
|
3148
|
+
function serviceTaskIsExternalAgent(body) {
|
|
3149
|
+
const src = String(body || '');
|
|
3150
|
+
AGENT_DEFINITION_RE.lastIndex = 0;
|
|
3151
|
+
let m;
|
|
3152
|
+
while ((m = AGENT_DEFINITION_RE.exec(src)) !== null) {
|
|
3153
|
+
if (parseXmlAttrs(m[1]).agentType === 'external') return true;
|
|
3154
|
+
}
|
|
3155
|
+
return false;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
// Opt-out namespace (issue #235): an external agent task authored with
|
|
3159
|
+
//
|
|
3160
|
+
// <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
|
|
3161
|
+
//
|
|
3162
|
+
// is EXCLUDED from the `--auto` set — served only by explicit subscription
|
|
3163
|
+
// (`--job-type`/profile, unioned unchanged with `autoTypes`). Only the exact
|
|
3164
|
+
// `value="false"` opts out (fail-safe: any other value, or absence, auto-subscribes).
|
|
3165
|
+
const AGENT_TASK_AUTO_SUBSCRIBE_PROP = `${AGENT_TASK_NS}.autoSubscribe`;
|
|
3166
|
+
|
|
3167
|
+
// Parse a raw element attribute string (`name="…" other='…'`) into a
|
|
3168
|
+
// `{ name: value }` map, walking LEFT-TO-RIGHT and consuming each COMPLETE quoted
|
|
3169
|
+
// value so text INSIDE one attribute's value can never be mistaken for a separate
|
|
3170
|
+
// attribute. This is the quote-aware core both the external-marker and the
|
|
3171
|
+
// auto-subscribe-opt-out scans read through: a plain "search the whole string for
|
|
3172
|
+
// `key=…`" would match e.g. `value='false'` embedded in another attribute's
|
|
3173
|
+
// quoted value and misclassify the element. First occurrence wins (XML attribute
|
|
3174
|
+
// names are unique). Attribute NAMES may be namespace-prefixed and carry XML name
|
|
3175
|
+
// chars (`:`/`-`/`.`); values hold any non-quote text. Case-sensitive: XML
|
|
3176
|
+
// attribute names are case-sensitive and the conventions here are literal.
|
|
3177
|
+
function parseXmlAttrs(attrs) {
|
|
3178
|
+
const out = Object.create(null);
|
|
3179
|
+
const re = /([:\w.\-]+)\s*=\s*(["'])([\s\S]*?)\2/g;
|
|
3180
|
+
let m;
|
|
3181
|
+
while ((m = re.exec(String(attrs || ''))) !== null) {
|
|
3182
|
+
if (!(m[1] in out)) out[m[1]] = m[3];
|
|
3183
|
+
}
|
|
3184
|
+
return out;
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
// Read a named attribute's value from a raw element's attribute string
|
|
3188
|
+
// (`name="…"`/`name='…'`), quote-style and order tolerant; undefined when absent.
|
|
3189
|
+
// Quote-aware (via `parseXmlAttrs`): only a REAL attribute matches, so neither a
|
|
3190
|
+
// `\b`-style boundary trick (`other-name`) NOR text nested inside another
|
|
3191
|
+
// attribute's quoted value can masquerade as the canonical `name`/`value`.
|
|
3192
|
+
// Case-sensitive: XML attribute names are case-sensitive and the convention is literal.
|
|
3193
|
+
function readXmlAttr(attrs, key) {
|
|
3194
|
+
return parseXmlAttrs(attrs)[key];
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
// True when `body` (a service task's inner XML) declares a
|
|
3198
|
+
// `<zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false">` —
|
|
3199
|
+
// attribute order tolerated (name/value read independently). Fail-safe: only the
|
|
3200
|
+
// literal `false` opts out.
|
|
3201
|
+
function serviceTaskOptsOutOfAutoSubscribe(body) {
|
|
3202
|
+
const src = String(body || '');
|
|
3203
|
+
if (!src.includes(AGENT_TASK_AUTO_SUBSCRIBE_PROP)) return false;
|
|
3204
|
+
// `property(?=[\s/>])` (not `property\b`): a `\b` boundary would also match a
|
|
3205
|
+
// foreign element named `<zeebe:property-extra …>`, which — if it carried the
|
|
3206
|
+
// same `name`/`value` attributes — could wrongly opt a task out. Require XML
|
|
3207
|
+
// whitespace or tag termination right after the exact element name.
|
|
3208
|
+
// CASE-SENSITIVE (no `i` flag): XML element names are case-sensitive and the
|
|
3209
|
+
// convention specifies the literal `property`, so a non-canonical `<zeebe:Property …>`
|
|
3210
|
+
// is NOT the opt-out element and must not remove a task from `--auto`.
|
|
3211
|
+
const propRe = /<(?:\w+:)?property(?=[\s/>])([^>]*?)\/?>/g;
|
|
3212
|
+
let m;
|
|
3213
|
+
while ((m = propRe.exec(src)) !== null) {
|
|
3214
|
+
if (readXmlAttr(m[1], 'name') !== AGENT_TASK_AUTO_SUBSCRIBE_PROP) continue;
|
|
3215
|
+
if (readXmlAttr(m[1], 'value') === 'false') return true;
|
|
3216
|
+
}
|
|
3217
|
+
return false;
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
// Strip XML comments (`<!-- … -->`) and CDATA sections (`<![CDATA[ … ]]>`) from a
|
|
3221
|
+
// fragment so marker/property detection never fires on INERT text: a commented-out
|
|
3222
|
+
// or CDATA-wrapped `agentDefinition`/`autoSubscribe` is not a live declaration and
|
|
3223
|
+
// must not auto-enrol (or silently suppress) a task.
|
|
3224
|
+
function stripXmlCommentsAndCdata(s) {
|
|
3225
|
+
return String(s || '')
|
|
3226
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
3227
|
+
.replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '');
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
// Walk `xml` collecting the service-task element ids whose inner body satisfies
|
|
3231
|
+
// `predicate(body)`, correlating back to `scanTaskDefinitions` leaves by
|
|
3232
|
+
// `elementId`. Shared by the external-marker and auto-subscribe-opt-out scans.
|
|
3233
|
+
function serviceTaskElementIds(xml, guardSubstring, predicate) {
|
|
3143
3234
|
const ids = new Set();
|
|
3144
|
-
const
|
|
3145
|
-
// Cheap guard:
|
|
3146
|
-
//
|
|
3147
|
-
//
|
|
3148
|
-
//
|
|
3149
|
-
|
|
3150
|
-
|
|
3235
|
+
const raw = String(xml || '');
|
|
3236
|
+
// Cheap guard on the RAW document: skip the full `<serviceTask>` walk when the
|
|
3237
|
+
// document cannot possibly match, so `--auto` enrolment loops don't parse every
|
|
3238
|
+
// deployed definition needlessly. (A marker that appears ONLY inside a comment/
|
|
3239
|
+
// CDATA still passes this guard, but the strip below then correctly drops it.)
|
|
3240
|
+
if (guardSubstring && !raw.includes(guardSubstring)) return ids;
|
|
3241
|
+
// Strip comments/CDATA from the WHOLE document BEFORE matching serviceTasks: a
|
|
3242
|
+
// marker/property inside a comment or CDATA is inert and must not drive enrolment
|
|
3243
|
+
// or opt-out, AND an inert `</serviceTask>` inside a comment/CDATA would otherwise
|
|
3244
|
+
// truncate the non-greedy task-body capture at that fake close — hiding a real
|
|
3245
|
+
// marker/property later in the same task (silently omitting it from `--auto`).
|
|
3246
|
+
const src = stripXmlCommentsAndCdata(raw);
|
|
3151
3247
|
const taskRe =
|
|
3152
3248
|
/<(?:\w+:)?serviceTask\b[^>]*?\bid\s*=\s*(["'])(.*?)\1[^>]*?>([\s\S]*?)<\/(?:\w+:)?serviceTask>/g;
|
|
3153
3249
|
let m;
|
|
3154
3250
|
while ((m = taskRe.exec(src)) !== null) {
|
|
3155
|
-
if (
|
|
3251
|
+
if (predicate(m[3])) ids.add(m[2]);
|
|
3156
3252
|
}
|
|
3157
3253
|
return ids;
|
|
3158
3254
|
}
|
|
3159
3255
|
|
|
3160
|
-
//
|
|
3161
|
-
//
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
//
|
|
3167
|
-
// `
|
|
3168
|
-
|
|
3169
|
-
|
|
3256
|
+
// The set of service-task element ids in `xml` carrying the external-agent
|
|
3257
|
+
// marker (`<zeebe:agentDefinition agentType="external">`).
|
|
3258
|
+
function externalAgentElementIds(xml) {
|
|
3259
|
+
return serviceTaskElementIds(xml, 'agentDefinition', serviceTaskIsExternalAgent);
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
// The set of service-task element ids in `xml` opted out of `--auto`
|
|
3263
|
+
// (`io.nanobpm.agentTask.autoSubscribe = "false"`).
|
|
3264
|
+
function autoSubscribeOptOutElementIds(xml) {
|
|
3265
|
+
return serviceTaskElementIds(xml, AGENT_TASK_AUTO_SUBSCRIBE_PROP, serviceTaskOptsOutOfAutoSubscribe);
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
// Scan one deployed BPMN document for its `--auto`-eligible *agent* task-definition
|
|
3269
|
+
// leaves: the subset of `@nanobpm/agentic` `demand.scanTaskDefinitions(xml)` leaves
|
|
3270
|
+
// that carry the single-convention external-agent marker (issue #235) — preferring
|
|
3271
|
+
// a package-supplied `leaf.external` flag, falling back to the local
|
|
3272
|
+
// `externalAgentElementIds` scan — MINUS any leaf that opts out via
|
|
3273
|
+
// `io.nanobpm.agentTask.autoSubscribe="false"`. Returns `{ taskType, process }`
|
|
3274
|
+
// leaves in first-occurrence order. The published `scanTaskDefinitions` is INJECTED
|
|
3275
|
+
// so this stays a pure, synchronous function; `readDeployedAgentJobTypes` supplies
|
|
3276
|
+
// the real one from the lazily-imported demand surface (`agentic.mjs`).
|
|
3170
3277
|
function scanAgentTaskLeaves(xml, scanTaskDefinitions) {
|
|
3171
3278
|
if (typeof scanTaskDefinitions !== 'function') {
|
|
3172
3279
|
throw new TypeError(
|
|
@@ -3175,9 +3282,17 @@ function scanAgentTaskLeaves(xml, scanTaskDefinitions) {
|
|
|
3175
3282
|
);
|
|
3176
3283
|
}
|
|
3177
3284
|
const src = String(xml || '');
|
|
3178
|
-
const
|
|
3285
|
+
const externalIds = externalAgentElementIds(src);
|
|
3286
|
+
const optedOutIds = autoSubscribeOptOutElementIds(src);
|
|
3179
3287
|
return scanTaskDefinitions(src)
|
|
3180
|
-
|
|
3288
|
+
// A package-supplied boolean `leaf.external` is AUTHORITATIVE: `false` is not
|
|
3289
|
+
// the same as absent — it explicitly classifies the leaf as non-external, so
|
|
3290
|
+
// it must exclude the leaf even when the local marker scan sees the marker.
|
|
3291
|
+
// Fall back to the local `externalAgentElementIds` scan ONLY when the package
|
|
3292
|
+
// does not supply a boolean flag.
|
|
3293
|
+
.filter((leaf) =>
|
|
3294
|
+
typeof leaf.external === 'boolean' ? leaf.external : externalIds.has(leaf.elementId))
|
|
3295
|
+
.filter((leaf) => !optedOutIds.has(leaf.elementId))
|
|
3181
3296
|
.map((leaf) => ({ taskType: leaf.taskType, process: leaf.process }));
|
|
3182
3297
|
}
|
|
3183
3298
|
|
|
@@ -3329,6 +3444,7 @@ async function createAgenticEndpoint(opts) {
|
|
|
3329
3444
|
// @param {string} [opts.worker] worker id stamped on activations
|
|
3330
3445
|
// @param {Array<{id: string, types?: Iterable<string>, capacity?: number}>} [opts.workers] registry seed
|
|
3331
3446
|
// @param {string} [opts.autoWorkerId] worker whose types the reconcile loop rewrites
|
|
3447
|
+
// @param {ReadonlyArray<string>} [opts.autoExtraTypes] explicit `--job-type` extras unioned into every reconcile write (survive the auto rewrite). MUST be an array (the supervisor gates the union on `.length`); a bare `Iterable`/`Set` would be silently dropped.
|
|
3332
3448
|
// @param {import('./supervisor.dist.js').AgenticEndpoint} [opts.agenticEndpoint] the ownership wire
|
|
3333
3449
|
// @param {object} [opts.agenticConfig] reconnect backoff config
|
|
3334
3450
|
// @param {string} [opts.scope] reconcile process-id scope narrowing
|
|
@@ -3363,6 +3479,7 @@ async function createSupervisorDeps(opts = {}) {
|
|
|
3363
3479
|
worker,
|
|
3364
3480
|
workers = [],
|
|
3365
3481
|
autoWorkerId,
|
|
3482
|
+
autoExtraTypes,
|
|
3366
3483
|
agenticEndpoint,
|
|
3367
3484
|
agenticConfig,
|
|
3368
3485
|
scope = '',
|
|
@@ -3437,6 +3554,7 @@ async function createSupervisorDeps(opts = {}) {
|
|
|
3437
3554
|
scan,
|
|
3438
3555
|
logger,
|
|
3439
3556
|
autoWorkerId,
|
|
3557
|
+
autoExtraTypes,
|
|
3440
3558
|
agenticEndpoint,
|
|
3441
3559
|
agenticConfig,
|
|
3442
3560
|
config: scope ? { ...config, scope } : config,
|
|
@@ -4157,17 +4275,35 @@ function githubCloneToken({ provider, authRef, secretResolver, ghAuthToken = ghA
|
|
|
4157
4275
|
// Clone repo into <runDir>/workspace and check out / create the working branch.
|
|
4158
4276
|
// Returns { workspaceDir, gitEnv, startSha, workingBranch, remote }. Throws a
|
|
4159
4277
|
// ProvisionError (token-redacted) on any git failure so the caller can shed.
|
|
4160
|
-
function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, logger = null, corr = '', _runGit = null }) {
|
|
4278
|
+
function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, logger = null, corr = '', abortSignal = null, _runGit = null }) {
|
|
4161
4279
|
// Test-only seam mirroring finalizeGit: route every git call through `runGitFn` so a
|
|
4162
4280
|
// deterministic test can observe the per-call timeouts drawn from the shared
|
|
4163
4281
|
// provisioning budget below. Defaults to the module `runGit` (production unchanged).
|
|
4164
4282
|
const runGitFn = _runGit || runGit;
|
|
4283
|
+
// #222: the git ops below are blocking spawnSync calls that cannot observe an
|
|
4284
|
+
// AbortSignal mid-call, so provisioning is made signal-aware by rechecking
|
|
4285
|
+
// BETWEEN operations: if a lock-loss race won, bail before the NEXT side effect
|
|
4286
|
+
// (the sha fetch/checkout, base fetch, or config writes) instead of pressing on.
|
|
4287
|
+
// The throw is a ProvisionError so the callsite's existing catch reaps the clone;
|
|
4288
|
+
// that catch rechecks the same signal and returns WITHOUT settling.
|
|
4289
|
+
const throwIfAborted = (stage) => {
|
|
4290
|
+
if (abortSignal && abortSignal.aborted === true) {
|
|
4291
|
+
throw new ProvisionError(`provisioning aborted during ${stage} — lease loss/force-stop won the race`);
|
|
4292
|
+
}
|
|
4293
|
+
};
|
|
4165
4294
|
const repo = envelope.repository;
|
|
4166
4295
|
if (!repo || !repo.url) throw new ProvisionError('repository.url is required to provision a workspace');
|
|
4167
4296
|
// #229: correlation suffix so git provisioning lines can be joined to the job /
|
|
4168
4297
|
// AgentInstance / relay channels (git logs carried no elementInstanceKey before).
|
|
4169
4298
|
const cs = corr ? ` [${corr}]` : '';
|
|
4170
4299
|
const workspaceDir = join(runDir, 'workspace');
|
|
4300
|
+
// #222: honor an ALREADY-aborted signal at the seam ENTRY, before the first repo
|
|
4301
|
+
// side effects (the askpass helper write and the clone). Without this, a signal
|
|
4302
|
+
// that flipped before provisionRepo was even called still wrote the askpass helper
|
|
4303
|
+
// and ran `git clone` before the first `throwIfAborted('post-clone')` fired — the
|
|
4304
|
+
// caller's gate has a race window, so the exported provisioning seam must refuse an
|
|
4305
|
+
// already-lost lease without starting any repository side effect.
|
|
4306
|
+
throwIfAborted('entry');
|
|
4171
4307
|
const askpass = writeAskpass(runDir, token);
|
|
4172
4308
|
const gitEnv = {
|
|
4173
4309
|
...process.env,
|
|
@@ -4252,10 +4388,22 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4252
4388
|
throw new ProvisionError(`git clone failed: ${gitErrorDetail(clone, token)}`);
|
|
4253
4389
|
}
|
|
4254
4390
|
|
|
4391
|
+
// #222: the clone (the dominant blocking op and the first repo side effect) is
|
|
4392
|
+
// done. Recheck the abort signal before the remaining side effects/network ops
|
|
4393
|
+
// (sha fetch+checkout, base fetch, config writes) so a lock-loss race that won
|
|
4394
|
+
// during the clone stops here rather than compounding the wasted work.
|
|
4395
|
+
throwIfAborted('post-clone');
|
|
4396
|
+
|
|
4255
4397
|
if (isSha) {
|
|
4256
4398
|
// The SHA may not be present under a shallow clone of the branch — fetch it
|
|
4257
4399
|
// explicitly (best effort), then check it out (detached HEAD).
|
|
4258
4400
|
const fetch = runGitFn([...credArgs(), 'fetch', '--no-tags', 'origin', commitSha], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
|
|
4401
|
+
// #222: the fetch above is its own blocking network op — recheck the abort signal
|
|
4402
|
+
// BEFORE the checkout mutates the working tree, so a lock-loss race that won while
|
|
4403
|
+
// `git fetch origin <sha>` was blocking stops here (cleanup/no-settle path) rather
|
|
4404
|
+
// than running `git checkout --detach` and continuing to mutate the throwaway repo
|
|
4405
|
+
// after cancellation.
|
|
4406
|
+
throwIfAborted('post-sha-fetch');
|
|
4259
4407
|
const co = runGitFn(['checkout', '--detach', commitSha], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
|
|
4260
4408
|
if (co.status !== 0) {
|
|
4261
4409
|
// Combine the fetch + checkout output (the real reason often lives in the
|
|
@@ -4266,6 +4414,9 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4266
4414
|
}
|
|
4267
4415
|
}
|
|
4268
4416
|
|
|
4417
|
+
// #222: recheck before the base-fetch network probes (another blocking op set).
|
|
4418
|
+
throwIfAborted('pre-base-fetch');
|
|
4419
|
+
|
|
4269
4420
|
// Optional base fetch: with a single-branch/shallow clone the head has no base
|
|
4270
4421
|
// and no merge-base, so a naive `git diff <base>` fails. When a base branch or
|
|
4271
4422
|
// sha is supplied, fetch it (respecting depth/filter) into a remote-tracking
|
|
@@ -4309,6 +4460,13 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4309
4460
|
}
|
|
4310
4461
|
}
|
|
4311
4462
|
}
|
|
4463
|
+
// #222: the pre-base-fetch snapshot probe above can itself BLOCK on the network
|
|
4464
|
+
// (the `ls-remote --heads` fallback when there is no local `origin/<base>`), so
|
|
4465
|
+
// `pre-base-fetch` (before the probe) is too early to cover an abort that wins
|
|
4466
|
+
// DURING it. Recheck here — after the snapshot probe, before the OPTIONAL base
|
|
4467
|
+
// fetch below — so a lock-loss race that landed while the snapshot ls-remote was
|
|
4468
|
+
// blocking stops before the base fetch mutates the throwaway repo (thread 4389).
|
|
4469
|
+
throwIfAborted('pre-base-fetch-op');
|
|
4312
4470
|
let base = '';
|
|
4313
4471
|
let baseFetchError;
|
|
4314
4472
|
// `baseRef` (branch/tag) and `baseSha` (raw commit) are mutually exclusive — a
|
|
@@ -4349,6 +4507,12 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4349
4507
|
}
|
|
4350
4508
|
}
|
|
4351
4509
|
|
|
4510
|
+
// #222: the base-fetch group above is the last blocking NETWORK op set. Recheck the
|
|
4511
|
+
// abort signal BEFORE the committer-config writes (which mutate .git/config) so a
|
|
4512
|
+
// lock-loss race that won while the base fetch / snapshot probes were blocking stops
|
|
4513
|
+
// here rather than mutating the throwaway repo's config after cancellation.
|
|
4514
|
+
throwIfAborted('post-base-fetch');
|
|
4515
|
+
|
|
4352
4516
|
// Give the harness a committer identity in case it commits (many do). Prefer
|
|
4353
4517
|
// the operator's real identity (git global / gh user) over the `nano-agent`
|
|
4354
4518
|
// fallback so autonomous commits are authored by the human running the fleet —
|
|
@@ -4356,8 +4520,17 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4356
4520
|
// is instead recorded as a PR comment (postAgentAttribution). Set via repo-
|
|
4357
4521
|
// level config, which overrides global, so the identity is deterministic.
|
|
4358
4522
|
const committer = resolveCommitterIdentity();
|
|
4523
|
+
// #222: `resolveCommitterIdentity()` runs blocking git/gh identity probes and the
|
|
4524
|
+
// two `git config` writes below MUTATE .git/config, so `post-base-fetch` (before
|
|
4525
|
+
// the identity resolution) is not the last gate. Recheck after identity resolution
|
|
4526
|
+
// and around each config write so an abort that wins during any of them stops
|
|
4527
|
+
// before starting the next blocking op rather than mutating the throwaway repo's
|
|
4528
|
+
// config after cancellation (suppressed advisory 4389).
|
|
4529
|
+
throwIfAborted('post-committer-identity');
|
|
4359
4530
|
runGitFn(['config', 'user.name', committer.name], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
|
|
4531
|
+
throwIfAborted('post-committer-name');
|
|
4360
4532
|
runGitFn(['config', 'user.email', committer.email], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
|
|
4533
|
+
throwIfAborted('post-committer-config');
|
|
4361
4534
|
// Config alone is not enough: git honours GIT_AUTHOR_*/GIT_COMMITTER_* OVER
|
|
4362
4535
|
// user.name/user.email config, so a placeholder GIT_AUTHOR_EMAIL inherited from
|
|
4363
4536
|
// the launch environment (e.g. `trial-merge@nano.local`) would still be stamped
|
|
@@ -4586,6 +4759,13 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4586
4759
|
// default could NOT be verified and no base was configured, fail closed (treat every
|
|
4587
4760
|
// such create as base-like) so an unverified default can never slip the guard (4444).
|
|
4588
4761
|
const createNamesBase = (name) => name === effectiveBase || (!!remoteDefaultBranch && name === remoteDefaultBranch) || defaultUnverified;
|
|
4762
|
+
// #222: the branch-state probes above (symref/HEAD reads, refBaseBranch and
|
|
4763
|
+
// remoteDefaultBranch resolution) are blocking git ops too. Recheck the abort signal
|
|
4764
|
+
// one last time BEFORE the branch-checkout decisions below, which run `git checkout
|
|
4765
|
+
// -B` — the working-tree mutation this issue guards. A lock-loss race that won while
|
|
4766
|
+
// those probes were blocking stops here rather than cutting/checking out a branch on
|
|
4767
|
+
// the throwaway repo after cancellation.
|
|
4768
|
+
throwIfAborted('pre-branch-checkout');
|
|
4589
4769
|
// Defense against silent work-loss (issue #231): committing on the base branch
|
|
4590
4770
|
// with intent to push is ALWAYS wrong for the PR flow — a push to the shared
|
|
4591
4771
|
// base races it and a non-ff reject strands the commits in this throwaway
|
|
@@ -4644,6 +4824,12 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4644
4824
|
else log.debug?.(`provisionRepo${cs}: no branch.create; push disabled → working read-only on '${checkedOut}'`);
|
|
4645
4825
|
}
|
|
4646
4826
|
}
|
|
4827
|
+
// #222: the branch-selection block above runs `git checkout -B` (the working-tree
|
|
4828
|
+
// mutation) plus its fallback-branch cuts. Recheck one last time AFTER those ops and
|
|
4829
|
+
// BEFORE the HEAD/base-tip probes below, so a lock-loss race that won DURING the
|
|
4830
|
+
// checkout stops at this boundary (cleanup + no-settle) rather than continuing
|
|
4831
|
+
// through the remaining synchronous provisioning until the later relay-open gate.
|
|
4832
|
+
throwIfAborted('post-branch-checkout');
|
|
4647
4833
|
const sha = runGitFn(['rev-parse', 'HEAD'], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
|
|
4648
4834
|
// Capture the base ref's SHA AT CLONE TIME (issue #229/#231 observability): the
|
|
4649
4835
|
// harness runs arbitrary code between here and finalizeGit and can itself advance
|
|
@@ -8336,6 +8522,47 @@ function localNetworkTccHint() {
|
|
|
8336
8522
|
].join('\n');
|
|
8337
8523
|
}
|
|
8338
8524
|
|
|
8525
|
+
/**
|
|
8526
|
+
* #222 — setup-phase abort gate. #221 makes the dispatch heartbeat `raceFirst`
|
|
8527
|
+
* interrupt the agent on a definitive lease loss, and `runAgentJob` honours that
|
|
8528
|
+
* by wiring the forwarded `AbortSignal` to killTree the harness. But `workAgent`
|
|
8529
|
+
* does real work BEFORE it reaches `runAgentJob` — prompt/AgentInstance setup,
|
|
8530
|
+
* repository provisioning, and `relaySessionFor` (which emits `lifecycle/open`,
|
|
8531
|
+
* the first transcript event). None of that setup path observes the signal, so if
|
|
8532
|
+
* a lock-loss race wins DURING setup the abandoned run would still create the
|
|
8533
|
+
* transcript husk (an empty `lifecycle/open`-only record) and perform repo side
|
|
8534
|
+
* effects before an eventually-fenced settle. Call this at each pre-`runAgentJob`
|
|
8535
|
+
* stage boundary: when the signal is already aborted it logs once and returns
|
|
8536
|
+
* `true` so the caller can skip that stage's side effect and return WITHOUT
|
|
8537
|
+
* settling (the lease is lost / the job is being yielded for retry). A missing or
|
|
8538
|
+
* never-aborted signal is a no-op (returns `false`), leaving the normal and
|
|
8539
|
+
* graceful-drain paths unchanged.
|
|
8540
|
+
*
|
|
8541
|
+
* @param {AbortSignal|null|undefined} abortSignal the run's interruption signal
|
|
8542
|
+
* @param {{ jobType?: string, jobKey?: string|number, stage?: string, logger?: { warn?: (msg: string) => void } }} [ctx]
|
|
8543
|
+
* @returns {boolean} true iff the run was aborted during setup and must stop
|
|
8544
|
+
*/
|
|
8545
|
+
function checkSetupAbort(abortSignal, { jobType, jobKey, stage, logger } = {}) {
|
|
8546
|
+
if (!abortSignal || abortSignal.aborted !== true) return false;
|
|
8547
|
+
// The invariant that holds at EVERY stage is the same: we stop and DO NOT settle,
|
|
8548
|
+
// so the job is yielded for retry. What has already happened by this point is NOT
|
|
8549
|
+
// the same across stages, though — the earlier "no repo side effects / no transcript
|
|
8550
|
+
// husk" wording was inaccurate at `agent-instance` (an instance may have minted, then
|
|
8551
|
+
// discarded here) and especially at `repo-provisioning`/`relay-open` (the throwaway
|
|
8552
|
+
// clone + config + branch setup may already exist). Describe only what is TRUE for
|
|
8553
|
+
// this stage so operators investigating a cleaned-up but partially-provisioned run
|
|
8554
|
+
// are not misled (issue #222).
|
|
8555
|
+
const sideEffectNote = stage === 'prompt'
|
|
8556
|
+
? 'stopping before any side effect (no transcript, no repo work, no settle)'
|
|
8557
|
+
: stage === 'agent-instance'
|
|
8558
|
+
? 'any AgentInstance minted this run is discarded (no COMPLETED update); stopping without a settle'
|
|
8559
|
+
: stage === 'repo-provisioning' || stage === 'relay-open'
|
|
8560
|
+
? 'any partially-provisioned throwaway workspace is removed best-effort on the way out (a failed removal is left for the age-gated reaper; the entry gate may have created none yet) and no transcript is completed; stopping without a settle'
|
|
8561
|
+
: 'stopping without a settle';
|
|
8562
|
+
logger?.warn?.(`[${jobType}] job ${jobKey} aborted during setup (${stage}) — lease loss/force-stop won the race; ${sideEffectNote} (the job is being yielded for retry).`);
|
|
8563
|
+
return true;
|
|
8564
|
+
}
|
|
8565
|
+
|
|
8339
8566
|
/**
|
|
8340
8567
|
* work — turn a hire profile into live Nano job workers (one per job-type in
|
|
8341
8568
|
* the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
|
|
@@ -8591,9 +8818,11 @@ async function workAgent(req, flags) {
|
|
|
8591
8818
|
// no capability, no app enrol endpoint, no channel connection. It is the
|
|
8592
8819
|
// mutually-exclusive counterpart to capability-resolved SERVE: in `--auto`
|
|
8593
8820
|
// the rank×capability matrix is bypassed entirely (any deployed agent job is
|
|
8594
|
-
// served, gated only by the leaf's canonical
|
|
8595
|
-
//
|
|
8596
|
-
//
|
|
8821
|
+
// served, gated only by the leaf's canonical external-agent marker —
|
|
8822
|
+
// `<zeebe:agentDefinition agentType="external">` (issue #235), preferring a
|
|
8823
|
+
// package-supplied boolean `leaf.external` flag and falling back to a local
|
|
8824
|
+
// scan, minus any leaf that opts out via
|
|
8825
|
+
// `io.nanobpm.agentTask.autoSubscribe="false"`), and the
|
|
8597
8826
|
// desired set is reconciled by polling the engine rather than watching the
|
|
8598
8827
|
// profile. `--auto-scope <process-id|prefix>` narrows the blast radius to one
|
|
8599
8828
|
// app/network; without it, every agent job type on the engine is served.
|
|
@@ -8970,6 +9199,15 @@ async function workAgent(req, flags) {
|
|
|
8970
9199
|
try {
|
|
8971
9200
|
logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
|
|
8972
9201
|
|
|
9202
|
+
// #222: setup-abort gate (stage: prompt). If a lock-loss race already won
|
|
9203
|
+
// before setup begins, stop now — before the prompt/AgentInstance fetch,
|
|
9204
|
+
// repo provisioning, or the first transcript event. Return WITHOUT settling
|
|
9205
|
+
// (the lease is lost / the job is being yielded), so no husk is created.
|
|
9206
|
+
// This gate sits AHEAD of every pre-setup failure/settlement branch (the
|
|
9207
|
+
// disk-budget shed below included): an already-aborted run must return
|
|
9208
|
+
// without settling rather than race the force-stop yield with a fail-settle.
|
|
9209
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'prompt', logger })) return;
|
|
9210
|
+
|
|
8973
9211
|
// Disk-budget admission shed: if the engine data root is below the free
|
|
8974
9212
|
// floor, don't start a container — fail (retryable) so work sheds until
|
|
8975
9213
|
// the reaper/host frees space.
|
|
@@ -8978,6 +9216,12 @@ async function workAgent(req, flags) {
|
|
|
8978
9216
|
if (!budget.ok) {
|
|
8979
9217
|
const freeMb = budget.free != null ? Math.round(budget.free / 1_048_576) : '?';
|
|
8980
9218
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
9219
|
+
// #222: diskBudgetOk() runs synchronous Docker probes for container jobs
|
|
9220
|
+
// (including a 10s spawnSync), so the abort can flip WHILE we were checking
|
|
9221
|
+
// disk. Recheck before the low-disk fail-settle: an already-lost lease must
|
|
9222
|
+
// return WITHOUT settling (yield the job) rather than be clobbered into a
|
|
9223
|
+
// retryable provisioning failure that races the force-stop yield.
|
|
9224
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'prompt', logger })) return;
|
|
8981
9225
|
logger.warn(`[${jobType}] job ${job.jobKey} shed — low disk (${freeMb}MB free); retries left ${retries}`);
|
|
8982
9226
|
return settleJob.fail({ errorMessage: `disk budget exceeded (only ${freeMb}MB free)`, retries, retryBackOff: 30_000 });
|
|
8983
9227
|
}
|
|
@@ -8990,6 +9234,7 @@ async function workAgent(req, flags) {
|
|
|
8990
9234
|
// (retryable) rather than run an agent with an empty prompt.
|
|
8991
9235
|
let promptResourceKey = null;
|
|
8992
9236
|
let basePromptOverride;
|
|
9237
|
+
let promptFetchError = null;
|
|
8993
9238
|
try {
|
|
8994
9239
|
// Fetch the prompt from the broker the SDK client is connected to,
|
|
8995
9240
|
// deriving base URL + auth from that client (not restConfig, whose base
|
|
@@ -9008,6 +9253,20 @@ async function workAgent(req, flags) {
|
|
|
9008
9253
|
logger.info(`[${jobType}] job ${job.jobKey} base prompt from linked resource key ${promptResourceKey} (linkName=${linked.linkName}, ${Buffer.byteLength(String(basePromptOverride), 'utf8')} bytes)`);
|
|
9009
9254
|
}
|
|
9010
9255
|
} catch (err) {
|
|
9256
|
+
// Capture — don't settle here. The prompt fetch above is AWAITED, so a
|
|
9257
|
+
// lock-loss race can win DURING it (surfacing as either a rejection here
|
|
9258
|
+
// or a value that then reaches the missing-secret settlement below). Defer
|
|
9259
|
+
// the settlement past the post-await abort recheck so an aborted run
|
|
9260
|
+
// returns WITHOUT settling rather than racing the force-stop yield.
|
|
9261
|
+
promptFetchError = err;
|
|
9262
|
+
}
|
|
9263
|
+
|
|
9264
|
+
// #222: recheck after the awaited prompt fetch (stage: prompt) — before the
|
|
9265
|
+
// prompt-fetch failure settlement above OR the missing-secret settlement
|
|
9266
|
+
// below. An abort that landed during the await returns WITHOUT settling.
|
|
9267
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'prompt', logger })) return;
|
|
9268
|
+
if (promptFetchError) {
|
|
9269
|
+
const err = promptFetchError;
|
|
9011
9270
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
9012
9271
|
const msg = err instanceof ProvisionError ? err.message : `prompt resource fetch failed: ${err.message}`;
|
|
9013
9272
|
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
@@ -9051,6 +9310,15 @@ async function workAgent(req, flags) {
|
|
|
9051
9310
|
const runId = randomUUID();
|
|
9052
9311
|
if (isContainer) liveRunIds.add(runId);
|
|
9053
9312
|
|
|
9313
|
+
// #222: setup-abort gate (stage: agent-instance). The prompt fetch above
|
|
9314
|
+
// awaited the broker, so a lock-loss race may have won during it. Stop
|
|
9315
|
+
// before minting the durable AgentInstance (a side effect) and return
|
|
9316
|
+
// without settling; release the just-allocated container run id.
|
|
9317
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'agent-instance', logger })) {
|
|
9318
|
+
if (isContainer) liveRunIds.delete(runId);
|
|
9319
|
+
return;
|
|
9320
|
+
}
|
|
9321
|
+
|
|
9054
9322
|
// #194: durable engine-native AgentInstance producer. For an `external`
|
|
9055
9323
|
// agent job (one carrying the activation's lease token + elementInstanceKey)
|
|
9056
9324
|
// mint an AgentInstance now — lease-gated on THIS activation — seeding the
|
|
@@ -9064,6 +9332,21 @@ async function workAgent(req, flags) {
|
|
|
9064
9332
|
// end the instance is driven to COMPLETED (success only) — `job.complete`
|
|
9065
9333
|
// fires exactly as before regardless.
|
|
9066
9334
|
let agentInstanceProducer = null;
|
|
9335
|
+
// #222: shared teardown for EVERY setup-abort return branch after the
|
|
9336
|
+
// producer has been created. There is no delete verb for a minted instance,
|
|
9337
|
+
// so `discard()` the producer: it makes it permanently inert (dropping any
|
|
9338
|
+
// late/in-flight create so an abandoned run can't mint an orphaned instance
|
|
9339
|
+
// after we return) and drains already-queued appends — WITHOUT driving a
|
|
9340
|
+
// COMPLETED update (that would try to complete an instance for a run being
|
|
9341
|
+
// yielded for retry). Covers BOTH an active producer AND a retry-pending one
|
|
9342
|
+
// whose armed create could otherwise settle late. Every abort branch below
|
|
9343
|
+
// (agent-instance, repo-provisioning entry, the provisionRepo-catch abort,
|
|
9344
|
+
// and relay-open) routes through this so none leaves the producer un-discarded.
|
|
9345
|
+
const discardAgentInstanceProducer = async () => {
|
|
9346
|
+
if (agentInstanceProducer?.active || agentInstanceProducer?.retryPending) {
|
|
9347
|
+
try { await agentInstanceProducer.discard(); } catch { /* best effort */ }
|
|
9348
|
+
}
|
|
9349
|
+
};
|
|
9067
9350
|
const agentInstanceOff = String(process.env.NANO_AGENT_INSTANCE || '').trim().toLowerCase() === 'off';
|
|
9068
9351
|
// #229: correlation stamp for the decision-point + outer-catch logs below,
|
|
9069
9352
|
// so the AgentInstance producer lifecycle can be joined to the relay/git/job
|
|
@@ -9094,6 +9377,25 @@ async function workAgent(req, flags) {
|
|
|
9094
9377
|
logger.debug?.(`[${jobType}] AgentInstance producer skipped (${aiCorr}) — ${agentInstanceOff ? 'NANO_AGENT_INSTANCE=off' : 'not an external agent job (no lease token / elementInstanceKey)'}.`);
|
|
9095
9378
|
}
|
|
9096
9379
|
|
|
9380
|
+
// #222: recheck after the awaited activate() (stage: agent-instance). The
|
|
9381
|
+
// pre-activate gate above only guards ENTRY; `activate()` then awaits an
|
|
9382
|
+
// uncancellable createAgentInstance round-trip, so a lock-loss race can win
|
|
9383
|
+
// DURING it (and a pending create may still mint a durable instance). Recheck
|
|
9384
|
+
// BEFORE the later setup-failure settlements (the malformed-repository refusal
|
|
9385
|
+
// below and the repo-provisioning path) so an aborted run returns WITHOUT
|
|
9386
|
+
// settling. There is no delete verb for a minted instance, so `discard()` the
|
|
9387
|
+
// producer: it makes it permanently inert (dropping any late create so an
|
|
9388
|
+
// abandoned run can't mint an orphaned instance after we return) and drains
|
|
9389
|
+
// already-queued appends — WITHOUT driving a COMPLETED update (that would try
|
|
9390
|
+
// to mint/complete an instance for a run being yielded for retry). This covers
|
|
9391
|
+
// BOTH an active producer AND a retry-pending one whose in-flight/armed create
|
|
9392
|
+
// could otherwise settle late and mint after the abort path returned.
|
|
9393
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'agent-instance', logger })) {
|
|
9394
|
+
await discardAgentInstanceProducer();
|
|
9395
|
+
if (isContainer) liveRunIds.delete(runId);
|
|
9396
|
+
return;
|
|
9397
|
+
}
|
|
9398
|
+
|
|
9097
9399
|
// Fail-closed on a half-specified repository envelope (issue #129,
|
|
9098
9400
|
// hardening 2): a `repository` block that declares intent (any field set)
|
|
9099
9401
|
// but whose `url` is absent or not a usable clone target almost always
|
|
@@ -9122,6 +9424,18 @@ async function workAgent(req, flags) {
|
|
|
9122
9424
|
const hasRepo = !isContainer && !!envelope.repository?.url;
|
|
9123
9425
|
let runDir = null;
|
|
9124
9426
|
let provisioned = null;
|
|
9427
|
+
// #222: setup-abort gate (stage: repo-provisioning). The AgentInstance
|
|
9428
|
+
// activate above awaited the broker, so a lock-loss race may have won.
|
|
9429
|
+
// Stop BEFORE cloning/branching (the first repo side effects) and return
|
|
9430
|
+
// without settling; release the just-allocated container run id.
|
|
9431
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'repo-provisioning', logger })) {
|
|
9432
|
+
// #222: an active/retry-pending producer minted before this gate must be
|
|
9433
|
+
// made inert too — otherwise a late create resolves after we return and
|
|
9434
|
+
// mints an orphaned AgentInstance, while an active one is never finalized.
|
|
9435
|
+
await discardAgentInstanceProducer();
|
|
9436
|
+
if (isContainer) liveRunIds.delete(runId);
|
|
9437
|
+
return;
|
|
9438
|
+
}
|
|
9125
9439
|
// The broker activation lock is owned by the single-owner runtime's dispatch
|
|
9126
9440
|
// lifecycle (supervisor/src/dispatch.ts): it extends the winner to the
|
|
9127
9441
|
// recovery window BEFORE this runner starts and heartbeats it on a Schedule
|
|
@@ -9137,7 +9451,7 @@ async function workAgent(req, flags) {
|
|
|
9137
9451
|
mkdirSync(workerNsDir, { recursive: true });
|
|
9138
9452
|
runDir = mkdtempSync(join(workerNsDir, 'run-'));
|
|
9139
9453
|
liveRunDirs.add(runDir);
|
|
9140
|
-
provisioned = provisionRepo({ envelope, token: repoToken, runDir, runId, timeoutMs: cloneTimeoutMs, logger, corr: aiCorr });
|
|
9454
|
+
provisioned = provisionRepo({ envelope, token: repoToken, runDir, runId, timeoutMs: cloneTimeoutMs, logger, corr: aiCorr, abortSignal });
|
|
9141
9455
|
if (provisioned.baseFetchError) {
|
|
9142
9456
|
logger.warn(`[${jobType}] job ${job.jobKey} base fetch failed (${aiCorr}) — ${oneLineLog(provisioned.baseFetchError)}; base...head diffs may be unavailable`);
|
|
9143
9457
|
}
|
|
@@ -9164,6 +9478,13 @@ async function workAgent(req, flags) {
|
|
|
9164
9478
|
} catch (err) {
|
|
9165
9479
|
if (runDir) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(runDir); }
|
|
9166
9480
|
if (isContainer) liveRunIds.delete(runId);
|
|
9481
|
+
// #222: guard the abort/error exit BEFORE settling. `provisionRepo` runs
|
|
9482
|
+
// blocking spawnSync clone/fetch/checkout ops and only observes the signal
|
|
9483
|
+
// BETWEEN them, so a lock-loss race that won mid-clone surfaces here as a
|
|
9484
|
+
// throw. If the run was aborted, return WITHOUT settling (the run-dir is
|
|
9485
|
+
// already reaped above) rather than fail-settle and race the force-stop
|
|
9486
|
+
// yield. The throwaway clone is the only residue, and it is reaped.
|
|
9487
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'repo-provisioning', logger })) { await discardAgentInstanceProducer(); return; }
|
|
9167
9488
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
9168
9489
|
const msg = err instanceof ProvisionError ? err.message : `provisioning error: ${err.message}`;
|
|
9169
9490
|
// #229: include the correlation keys — a clone/checkout failure occurs
|
|
@@ -9221,6 +9542,24 @@ async function workAgent(req, flags) {
|
|
|
9221
9542
|
// cockpit steer-in fanned back to this job's PTY by the runtime's steer
|
|
9222
9543
|
// router. Only when the worker is enrolled (a live agentic plane); closed
|
|
9223
9544
|
// in the finally so its steer subscription never leaks across jobs.
|
|
9545
|
+
// #222: setup-abort gate (stage: relay-open). This is the last gate before
|
|
9546
|
+
// `relaySessionFor` emits `lifecycle/open` — the FIRST transcript event and
|
|
9547
|
+
// the source of the empty transcript husk this issue targets. If a lock-loss
|
|
9548
|
+
// race won during provisioning, stop here: never open the relay session (no
|
|
9549
|
+
// husk), reap the throwaway clone/run dir so no stale workspace is left, and
|
|
9550
|
+
// return without settling. Runs BEFORE the try/finally below, so the run-dir
|
|
9551
|
+
// reaping is done inline here.
|
|
9552
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'relay-open', logger })) {
|
|
9553
|
+
// #222: after repository setup the producer may be active/retry-pending, so
|
|
9554
|
+
// discard it before returning too — a late create would otherwise mint an
|
|
9555
|
+
// orphaned instance after the runner exits, and an active one stays
|
|
9556
|
+
// unfinalized. This return only reaped the run dir before.
|
|
9557
|
+
await discardAgentInstanceProducer();
|
|
9558
|
+
if (runDir) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(runDir); }
|
|
9559
|
+
if (isContainer) liveRunIds.delete(runId);
|
|
9560
|
+
return;
|
|
9561
|
+
}
|
|
9562
|
+
|
|
9224
9563
|
let relaySession = null;
|
|
9225
9564
|
if (agenticPlane) {
|
|
9226
9565
|
// #229: thread the correlation join keys + a lazy AgentInstance-key getter
|
|
@@ -9592,6 +9931,11 @@ async function workAgent(req, flags) {
|
|
|
9592
9931
|
worker: workerName,
|
|
9593
9932
|
workers: [{ id: workerName, types: jobTypes, capacity: 1 }],
|
|
9594
9933
|
autoWorkerId: autoMode ? workerName : undefined,
|
|
9934
|
+
// Explicit `--job-type` extras must outlive the first reconcile: the runtime's
|
|
9935
|
+
// reconcile rewrites the --auto worker's set from the engine scan, so pass the
|
|
9936
|
+
// extras through to be unioned into every reconcile write (else an explicit
|
|
9937
|
+
// subscription stops being served once reconcile succeeds).
|
|
9938
|
+
autoExtraTypes: autoMode && extraJobTypes.length > 0 ? extraJobTypes : undefined,
|
|
9595
9939
|
scope: autoScope,
|
|
9596
9940
|
// The ONE multiplexed host connection (issue #173): the runtime owns its
|
|
9597
9941
|
// connect/reconnect/resync + teardown lifecycle. Omitted (undefined) when the
|
|
@@ -15086,7 +15430,10 @@ export {
|
|
|
15086
15430
|
diffJobTypes,
|
|
15087
15431
|
parseJobTypeFlags,
|
|
15088
15432
|
scanAgentTaskLeaves,
|
|
15089
|
-
|
|
15433
|
+
serviceTaskIsExternalAgent,
|
|
15434
|
+
externalAgentElementIds,
|
|
15435
|
+
serviceTaskOptsOutOfAutoSubscribe,
|
|
15436
|
+
autoSubscribeOptOutElementIds,
|
|
15090
15437
|
readDeployedAgentJobTypes,
|
|
15091
15438
|
resolveAutoJobTypes,
|
|
15092
15439
|
loadSupervisorRuntime,
|
|
@@ -15104,6 +15451,7 @@ export {
|
|
|
15104
15451
|
withIpv4FirstNodeOptions,
|
|
15105
15452
|
DNS_RESULT_ORDER_IPV4_FIRST,
|
|
15106
15453
|
workAgent,
|
|
15454
|
+
checkSetupAbort,
|
|
15107
15455
|
derivePollTimeoutMs,
|
|
15108
15456
|
AGENT_TASK_NS,
|
|
15109
15457
|
AGENT_RESULT_KEY,
|