@coreplane/switchboard 1.248.0 → 1.249.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/dist/assets/config/config.example.yaml +43 -17
- package/dist/assets/deploy/cloudflare/preflight.mjs +21 -19
- package/dist/assets/deploy/cloudflare-memory/worker.ts +31 -0
- package/dist/assets/deploy/cloudflare-resident/drain.ts +109 -0
- package/dist/assets/deploy/cloudflare-resident/threadErr.ts +32 -3
- package/dist/assets/deploy/cloudflare-resident/worker.ts +174 -10
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +56 -9
- package/dist/assets/deploy/secrets.manifest.json +6 -0
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +4 -4
- package/dist/assets/src/core/budgets.ts +24 -0
- package/dist/assets/src/core/coordinator/contract.ts +4 -3
- package/dist/assets/src/core/coordinator/driver.ts +40 -7
- package/dist/assets/src/core/modelCard.ts +348 -0
- package/dist/assets/src/core/modelPricing.ts +14 -5
- package/dist/assets/src/core/modelRegistry.ts +51 -0
- package/dist/assets/src/core/provider.ts +103 -0
- package/dist/assets/src/core/refusal.ts +181 -0
- package/dist/assets/src/core/runEvents.ts +43 -0
- package/dist/assets/src/core/ship/coordinator.ts +71 -11
- package/dist/assets/src/core/ship/handoff.ts +54 -19
- package/dist/assets/src/core/trace/workerTrace.ts +9 -3
- package/dist/assets/src/core/types.ts +327 -0
- package/dist/assets/src/deploy/liveGate.ts +35 -0
- package/dist/assets/src/deploy/restart.ts +12 -11
- package/dist/assets/src/execution/sandboxErrors.ts +114 -4
- package/dist/assets/web/dist/.vite/manifest.json +30 -30
- package/dist/assets/web/dist/assets/DeliveryPage-DF4aQypG.js +1 -0
- package/dist/assets/web/dist/assets/{HomePage-DYxC0izY.js → HomePage-BpQRky8B.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-DLIpWYOc.js → ResidentDetailPage-BIUXyz6K.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-6LipuDjR.js → ResidentsIndexPage-BZymgSAb.js} +1 -1
- package/dist/assets/web/dist/assets/{RunFoldRow-V-iSy64e.js → RunFoldRow-3m4CPRI4.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-DUalB1u2.js → RunRoutePage-bgkjkA0p.js} +3 -3
- package/dist/assets/web/dist/assets/{RunsIndexPage-B9Ba1KdD.js → RunsIndexPage-8S944AzB.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-KdjLtD_7.js → ScheduledPage-8bBtG9y3.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-IT5l_NaL.js → SettingsPage-DQeNvfaV.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-DBHAl4Il.js → StatusDot-BPE5syBa.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-_LEjptLV.js → Tooltip-DkoeZfTs.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-BUzw--Ii.js +1 -0
- package/dist/assets/web/dist/assets/{dist-BcYPGOBL.js → dist-D11y9ZJ4.js} +1 -1
- package/dist/assets/web/dist/assets/{main-DUfSE0dj.js → main-B6LcgNM6.js} +2 -2
- package/dist/cli.js +2451 -1009
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/DeliveryPage-NP4g6bQd.js +0 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-DPBsvGPR.js +0 -1
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
//
|
|
14
14
|
// Route surface (JSON in/out; every route below requires a bearer secret):
|
|
15
15
|
// admin scope POST /onboard /offboard /reconfigure /rebuild /debug (all ops)
|
|
16
|
+
// drain scope POST /drain /undrain (admin implied) — the deploy's bearer, nothing else
|
|
16
17
|
// read scope GET /residents POST /debug ops info|schedules|threads only (admin implied)
|
|
17
18
|
// operator scope POST /attach /detach /exec /read /write /op GET /status (state, reason, inFlight)
|
|
18
19
|
// unauthenticated GET /healthz (deploy wake ping; touches no DO)
|
|
@@ -158,6 +159,7 @@ import {
|
|
|
158
159
|
} from "../../src/core/schedules.js";
|
|
159
160
|
import type { ResidentLifecycleState } from "../../src/execution/residentState.js";
|
|
160
161
|
import { RestoreWaiters } from "../../src/execution/restoreWaiters.js";
|
|
162
|
+
import { isRuntimeBusySignal, SandboxRuntimeBusyError } from "../../src/execution/sandboxErrors.js";
|
|
161
163
|
import {
|
|
162
164
|
decisivePull,
|
|
163
165
|
effectiveLimits,
|
|
@@ -323,11 +325,13 @@ import {
|
|
|
323
325
|
} from "../../src/execution/residentDepsStore.js";
|
|
324
326
|
import { buildId, injectedBuildStamp } from "../../src/deploy/buildStamp.js";
|
|
325
327
|
import { createRefreshInstance, createRefreshInstanceNow, type RefreshInstanceParams } from "./refresh";
|
|
328
|
+
import { drainRefusal, liveDrain, parseDrainRequest, type DrainRecord } from "./drain";
|
|
326
329
|
import {
|
|
327
330
|
ControlResetError,
|
|
328
331
|
RuntimeReplacedError,
|
|
329
332
|
controlResetErr,
|
|
330
333
|
execFailureDocument,
|
|
334
|
+
runtimeBusyErr,
|
|
331
335
|
runtimeReplacedErr,
|
|
332
336
|
selfAndCauses,
|
|
333
337
|
threadErrBuilders,
|
|
@@ -417,6 +421,10 @@ export interface Env {
|
|
|
417
421
|
* (info, schedules, threads) — for dashboards and humans who need to look,
|
|
418
422
|
* never to change anything. Unset = no read scope exists. */
|
|
419
423
|
RESIDENT_READ_TOKEN?: string;
|
|
424
|
+
/** Optional drain-only bearer (item 69): POST /drain and /undrain, nothing
|
|
425
|
+
* else — what a release deploy holds so it can close the fleet without the
|
|
426
|
+
* admin bearer. Unset = only admin can drain. */
|
|
427
|
+
RESIDENT_DRAIN_TOKEN?: string;
|
|
420
428
|
// GitHub App identity for minting installation tokens inside residents
|
|
421
429
|
// (provisioned via `npm run secrets` from deploy/secrets.manifest.json; when
|
|
422
430
|
// unset, clones/fetches run anonymously —
|
|
@@ -843,6 +851,18 @@ function isRuntimeUnreachable(err: unknown): boolean {
|
|
|
843
851
|
for (const link of selfAndCauses(err)) if (isRuntimeUnreachableSignal(link)) return true;
|
|
844
852
|
return false;
|
|
845
853
|
}
|
|
854
|
+
|
|
855
|
+
/** Did the platform refuse the connect inside its own accept allowance
|
|
856
|
+
* (docs/reference/specs/resident-repos.md item 68; execution.md item 28)? The
|
|
857
|
+
* platform's own wording — a plain `Error`, the SDK hands it on unwrapped —
|
|
858
|
+
* anywhere in the cause chain; its words blame load, which the platform never
|
|
859
|
+
* measured and an idle container has disproved. Asked only of a spawn-phase
|
|
860
|
+
* error, after `isRuntimeReplacement`: such a container is neither replaced
|
|
861
|
+
* nor silent for good, and a command's own output never gets here. */
|
|
862
|
+
function isRuntimeBusy(err: unknown): boolean {
|
|
863
|
+
for (const link of selfAndCauses(err)) if (isRuntimeBusySignal(link)) return true;
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
846
866
|
/** Trailing slice of one string for an error reason. Command RESULTS are not
|
|
847
867
|
* described here — `describeStepFailure` owns that, because choosing between
|
|
848
868
|
* the two streams is what lost a diagnosis (residentStepReport.ts). */
|
|
@@ -1347,6 +1367,11 @@ const registryKey = (resource: string) => `${REGISTRY_KEY_PREFIX}${resource}`;
|
|
|
1347
1367
|
/** Registry-DO key for the admin test overrides (gc.ts `StoredTestOverrides`).
|
|
1348
1368
|
* Deliberately OUTSIDE the `resident:` prefix so it never counts as a slot. */
|
|
1349
1369
|
const TEST_OVERRIDES_KEY = "testOverrides";
|
|
1370
|
+
/** Registry-DO key for the fleet drain (drain.ts; docs/reference/specs/resident-repos.md
|
|
1371
|
+
* item 69). Outside the `resident:` prefix like the overrides, so it never
|
|
1372
|
+
* counts as a slot; it survives the isolate swap a deploy performs, which is
|
|
1373
|
+
* why the record carries its own end. */
|
|
1374
|
+
const DRAIN_KEY = "drain";
|
|
1350
1375
|
|
|
1351
1376
|
type OnboardResult = { ok: true; record: ResidentRecord } | { ok: false; status: number; error: string };
|
|
1352
1377
|
|
|
@@ -1462,6 +1487,25 @@ export class ResidentRegistryDO extends DurableObject<Env> {
|
|
|
1462
1487
|
async remove(resource: string): Promise<boolean> {
|
|
1463
1488
|
return this.ctx.storage.delete(registryKey(resource));
|
|
1464
1489
|
}
|
|
1490
|
+
|
|
1491
|
+
/** The stored drain record as it is — `liveDrain` (drain.ts) decides at the
|
|
1492
|
+
* caller's clock whether it is in force; the registry keeps no clock of its
|
|
1493
|
+
* own so an expired record is read the same by every route. */
|
|
1494
|
+
async getDrain(): Promise<unknown> {
|
|
1495
|
+
return (await this.ctx.storage.get(DRAIN_KEY)) ?? null;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/** Admin-only by construction (reached solely via POST /drain): replaces
|
|
1499
|
+
* whatever drain stood — a second deploy's drain extends the first's. */
|
|
1500
|
+
async setDrain(record: DrainRecord): Promise<DrainRecord> {
|
|
1501
|
+
await this.ctx.storage.put(DRAIN_KEY, record);
|
|
1502
|
+
return record;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/** Admin-only by construction (POST /undrain): true when a record was there. */
|
|
1506
|
+
async clearDrain(): Promise<boolean> {
|
|
1507
|
+
return this.ctx.storage.delete(DRAIN_KEY);
|
|
1508
|
+
}
|
|
1465
1509
|
}
|
|
1466
1510
|
|
|
1467
1511
|
// ---------------------------------------------------------------------------
|
|
@@ -2027,6 +2071,14 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
2027
2071
|
// read as a replaced container.
|
|
2028
2072
|
if (isControlReset(err)) throw new ControlResetError("spawn", err);
|
|
2029
2073
|
if (!isRuntimeReplacement(err)) {
|
|
2074
|
+
// The container is running but did not accept the SDK's connect inside
|
|
2075
|
+
// the platform's own allowance (item 68): a command already running in
|
|
2076
|
+
// it has its cores. Nothing started, the worktree is as it was, and the
|
|
2077
|
+
// container accepts again in moments — the typed word, for the thread
|
|
2078
|
+
// routes to answer with the wait token; never counted as unreachable.
|
|
2079
|
+
if (isRuntimeBusy(err)) {
|
|
2080
|
+
throw new SandboxRuntimeBusyError({ containerId: this.ctx.id.toString(), cause: errMsg(err) });
|
|
2081
|
+
}
|
|
2030
2082
|
// The control port never answered the SDK's connect (its 30 s abort,
|
|
2031
2083
|
// raised inside the wake path): no process started and nothing about
|
|
2032
2084
|
// the repository is known. Count it in storage — the ladder of item 64
|
|
@@ -4861,6 +4913,20 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4861
4913
|
): Promise<AttachOk | ThreadErr> {
|
|
4862
4914
|
try {
|
|
4863
4915
|
await this.ensureHydrated();
|
|
4916
|
+
// The fleet drain (item 69): a deploy is waiting for the runs in flight
|
|
4917
|
+
// to end, and a NEW run's attach is refused with the record the bot
|
|
4918
|
+
// waits on — a real 503 in the streamed document, read by the client as
|
|
4919
|
+
// `draining`, never as the platform's transient. A run already in flight
|
|
4920
|
+
// — registered from its attach to its release (item 44) — re-attaches
|
|
4921
|
+
// through: a rolled container, an evicted worktree, a resumed run are
|
|
4922
|
+
// the runs the drain waits FOR, and refusing them would hold the fleet
|
|
4923
|
+
// closed on the run it is closed for. Read before the image reconcile so
|
|
4924
|
+
// a refused attach never restarts a container.
|
|
4925
|
+
const drain = await this.fleetDrain();
|
|
4926
|
+
if (drain && !(await this.ctx.storage.get(runRegKey(threadKey)))) {
|
|
4927
|
+
const refusal: ThreadErr & { draining: DrainRecord } = drainRefusal(drain);
|
|
4928
|
+
return refusal;
|
|
4929
|
+
}
|
|
4864
4930
|
const resourceId = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
|
|
4865
4931
|
if (await this.reconcileImage("attach")) {
|
|
4866
4932
|
return {
|
|
@@ -4937,6 +5003,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4937
5003
|
state: s.state,
|
|
4938
5004
|
stateReason: s.reason,
|
|
4939
5005
|
reason: s.reason,
|
|
5006
|
+
cause: "system",
|
|
4940
5007
|
};
|
|
4941
5008
|
}
|
|
4942
5009
|
// `resourceId` was read by the caller a moment ago (item 15 of the audit:
|
|
@@ -4952,7 +5019,12 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4952
5019
|
// Typed `reason` beside the words: the client reads the field — a refusal no
|
|
4953
5020
|
// wait clears, unlike the restore window's 503s — never the sentence.
|
|
4954
5021
|
if (!record || !facts)
|
|
4955
|
-
return {
|
|
5022
|
+
return {
|
|
5023
|
+
error: "not-serviceable: registry record or repo facts missing",
|
|
5024
|
+
status: 503,
|
|
5025
|
+
reason: "unregistered",
|
|
5026
|
+
cause: "system",
|
|
5027
|
+
};
|
|
4956
5028
|
|
|
4957
5029
|
// The binding's ref wins for the thread's whole life, with one exception
|
|
4958
5030
|
// (item 16): a thread bound to the repo default for want of a named branch
|
|
@@ -4981,6 +5053,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4981
5053
|
status: 409,
|
|
4982
5054
|
needs: "ref",
|
|
4983
5055
|
defaultRef: facts.defaultRef,
|
|
5056
|
+
cause: "request",
|
|
4984
5057
|
};
|
|
4985
5058
|
}
|
|
4986
5059
|
const worktreePath = prior?.worktreePath ?? (await threadWorktreePath(threadKey, ref));
|
|
@@ -5102,7 +5175,14 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5102
5175
|
} catch (err) {
|
|
5103
5176
|
if (err instanceof MirrorBusyError) {
|
|
5104
5177
|
const s = await this.getStatus();
|
|
5105
|
-
return {
|
|
5178
|
+
return {
|
|
5179
|
+
error: errMsg(err),
|
|
5180
|
+
status: 503,
|
|
5181
|
+
state: s.state,
|
|
5182
|
+
stateReason: s.reason,
|
|
5183
|
+
reason: "mirror-busy",
|
|
5184
|
+
cause: "system",
|
|
5185
|
+
};
|
|
5106
5186
|
}
|
|
5107
5187
|
return catchAllErr(err, "attach-failed");
|
|
5108
5188
|
}
|
|
@@ -5404,17 +5484,30 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5404
5484
|
return { error: `reuse-refused: ${err.why}`, status: 409, needs: "recreate" };
|
|
5405
5485
|
if (err instanceof MirrorBusyError) {
|
|
5406
5486
|
const s = await this.getStatus();
|
|
5407
|
-
return {
|
|
5487
|
+
return {
|
|
5488
|
+
error: errMsg(err),
|
|
5489
|
+
status: 503,
|
|
5490
|
+
state: s.state,
|
|
5491
|
+
stateReason: s.reason,
|
|
5492
|
+
reason: "mirror-busy",
|
|
5493
|
+
cause: "system",
|
|
5494
|
+
};
|
|
5408
5495
|
}
|
|
5409
5496
|
if (err instanceof StepError && err.step === "unknown-ref") {
|
|
5410
|
-
return { error: `unknown-ref: ${err.message}`, status: 400 };
|
|
5497
|
+
return { error: `unknown-ref: ${err.message}`, status: 400, cause: "request" };
|
|
5411
5498
|
}
|
|
5412
5499
|
if (err instanceof StepError && err.step === "stale-tip") {
|
|
5413
5500
|
// Item 51: not a resident fault and not a caller fault — a fact about
|
|
5414
5501
|
// the mirror at this instant. 409 with the state, so the bot's named
|
|
5415
5502
|
// fallback runs cold at the commit it asked for.
|
|
5416
5503
|
const s = await this.getStatus();
|
|
5417
|
-
return {
|
|
5504
|
+
return {
|
|
5505
|
+
error: `stale-tip: ${err.message}`,
|
|
5506
|
+
status: 409,
|
|
5507
|
+
state: s.state,
|
|
5508
|
+
reason: "stale-tip",
|
|
5509
|
+
cause: "system",
|
|
5510
|
+
};
|
|
5418
5511
|
}
|
|
5419
5512
|
return this.attachFailed(err);
|
|
5420
5513
|
}
|
|
@@ -5449,7 +5542,14 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5449
5542
|
// worktree lock above, so the bot-side fallback can retry.
|
|
5450
5543
|
if (err instanceof MirrorBusyError) {
|
|
5451
5544
|
const s = await this.getStatus();
|
|
5452
|
-
return {
|
|
5545
|
+
return {
|
|
5546
|
+
error: errMsg(err),
|
|
5547
|
+
status: 503,
|
|
5548
|
+
state: s.state,
|
|
5549
|
+
stateReason: s.reason,
|
|
5550
|
+
reason: "mirror-busy",
|
|
5551
|
+
cause: "system",
|
|
5552
|
+
};
|
|
5453
5553
|
}
|
|
5454
5554
|
return this.attachFailed(err);
|
|
5455
5555
|
}
|
|
@@ -6293,6 +6393,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6293
6393
|
state: s.state,
|
|
6294
6394
|
stateReason: s.reason,
|
|
6295
6395
|
reason: s.reason,
|
|
6396
|
+
cause: "system",
|
|
6296
6397
|
};
|
|
6297
6398
|
}
|
|
6298
6399
|
const binding = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(threadKey));
|
|
@@ -6381,6 +6482,9 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6381
6482
|
// container is unchanged, so no `replacedExecAnswer` gate applies.
|
|
6382
6483
|
if (err instanceof ControlResetError) return controlResetErr(err);
|
|
6383
6484
|
if (err instanceof RuntimeReplacedError) return this.replacedExecAnswer(err);
|
|
6485
|
+
// A refused connect at the command's spawn (item 68): the wait token,
|
|
6486
|
+
// the command never started.
|
|
6487
|
+
if (err instanceof SandboxRuntimeBusyError) return runtimeBusyErr(err);
|
|
6384
6488
|
throw err;
|
|
6385
6489
|
}
|
|
6386
6490
|
}
|
|
@@ -6461,6 +6565,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6461
6565
|
} catch (err) {
|
|
6462
6566
|
if (err instanceof ControlResetError) return controlResetErr(err);
|
|
6463
6567
|
if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
|
|
6568
|
+
if (err instanceof SandboxRuntimeBusyError) return runtimeBusyErr(err);
|
|
6464
6569
|
throw err;
|
|
6465
6570
|
}
|
|
6466
6571
|
if (r.exitCode !== 0 || r.timedOut) return { error: `read-failed: ${describeStepFailure(r)}`, status: 404 };
|
|
@@ -6510,6 +6615,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6510
6615
|
} catch (err) {
|
|
6511
6616
|
if (err instanceof ControlResetError) return controlResetErr(err);
|
|
6512
6617
|
if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
|
|
6618
|
+
if (err instanceof SandboxRuntimeBusyError) return runtimeBusyErr(err);
|
|
6513
6619
|
throw err;
|
|
6514
6620
|
}
|
|
6515
6621
|
}
|
|
@@ -6557,6 +6663,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6557
6663
|
} catch (err) {
|
|
6558
6664
|
if (err instanceof ControlResetError) return controlResetErr(err);
|
|
6559
6665
|
if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
|
|
6666
|
+
if (err instanceof SandboxRuntimeBusyError) return runtimeBusyErr(err);
|
|
6560
6667
|
const step = err instanceof StepError ? ` at ${err.step}` : "";
|
|
6561
6668
|
return { error: `write-failed${step}: ${errMsg(err)}`, status: 400 };
|
|
6562
6669
|
}
|
|
@@ -6781,6 +6888,19 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6781
6888
|
const threadOps = [...this.threadOpsInFlight.values()].reduce((a, n) => a + n, 0);
|
|
6782
6889
|
return threadOps + this.opUsersInUse.size + this.attachesInFlight;
|
|
6783
6890
|
}
|
|
6891
|
+
/** The fleet drain in force (item 69), read from the registry at this clock;
|
|
6892
|
+
* a registry that cannot be read is NO drain: a run must never fail because
|
|
6893
|
+
* a flag could not be read, and the deploy's own preflight fails closed on
|
|
6894
|
+
* its side (an unknown fleet refuses the deploy), so the failure lands on
|
|
6895
|
+
* the deploy, never on the run. Said in the log. */
|
|
6896
|
+
private async fleetDrain(): Promise<DrainRecord | null> {
|
|
6897
|
+
try {
|
|
6898
|
+
return liveDrain(await this.registry().getDrain(), systemClock());
|
|
6899
|
+
} catch (err) {
|
|
6900
|
+
console.warn(`[drain] registry unreadable at attach — treating as no drain: ${errMsg(err)}`);
|
|
6901
|
+
return null;
|
|
6902
|
+
}
|
|
6903
|
+
}
|
|
6784
6904
|
/** In-flight activity for the deploy preflight (GET /status, GET /residents).
|
|
6785
6905
|
* The in-memory counters (a fresh isolate answers 0 for them — nothing of
|
|
6786
6906
|
* THEIRS survived to be interrupted) plus the durable run registrations:
|
|
@@ -6928,6 +7048,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6928
7048
|
state: s.state,
|
|
6929
7049
|
stateReason: s.reason,
|
|
6930
7050
|
reason: s.reason,
|
|
7051
|
+
cause: "system",
|
|
6931
7052
|
};
|
|
6932
7053
|
}
|
|
6933
7054
|
// One storage round trip for the two facts; the registry lookup stays (an
|
|
@@ -6939,7 +7060,12 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6939
7060
|
// Typed `reason` beside the words: the client reads the field — a refusal no
|
|
6940
7061
|
// wait clears, unlike the restore window's 503s — never the sentence.
|
|
6941
7062
|
if (!record || !facts)
|
|
6942
|
-
return {
|
|
7063
|
+
return {
|
|
7064
|
+
error: "not-serviceable: registry record or repo facts missing",
|
|
7065
|
+
status: 503,
|
|
7066
|
+
reason: "unregistered",
|
|
7067
|
+
cause: "system",
|
|
7068
|
+
};
|
|
6943
7069
|
const command = record.commands[op];
|
|
6944
7070
|
if (!command) return { error: `op-unavailable: the command table has no "${op}" entry`, status: 400 };
|
|
6945
7071
|
|
|
@@ -7035,10 +7161,17 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
7035
7161
|
} catch (err) {
|
|
7036
7162
|
if (err instanceof MirrorBusyError) {
|
|
7037
7163
|
const s = await this.getStatus();
|
|
7038
|
-
return {
|
|
7164
|
+
return {
|
|
7165
|
+
error: errMsg(err),
|
|
7166
|
+
status: 503,
|
|
7167
|
+
state: s.state,
|
|
7168
|
+
stateReason: s.reason,
|
|
7169
|
+
reason: "mirror-busy",
|
|
7170
|
+
cause: "system",
|
|
7171
|
+
};
|
|
7039
7172
|
}
|
|
7040
7173
|
if (err instanceof StepError && err.step === "unknown-ref") {
|
|
7041
|
-
return { error: `unknown-ref: ${err.message}`, status: 400 };
|
|
7174
|
+
return { error: `unknown-ref: ${err.message}`, status: 400, cause: "request" };
|
|
7042
7175
|
}
|
|
7043
7176
|
// A step that failed is named and deterministic; a throw no step named is
|
|
7044
7177
|
// typed by the one builder every such 500 goes through.
|
|
@@ -7821,7 +7954,7 @@ function timingSafeEqual(a: string, b: string): boolean {
|
|
|
7821
7954
|
return diff === 0;
|
|
7822
7955
|
}
|
|
7823
7956
|
|
|
7824
|
-
type Scope = "admin" | "operator" | "read";
|
|
7957
|
+
type Scope = "admin" | "operator" | "read" | "drain";
|
|
7825
7958
|
|
|
7826
7959
|
/** Which token a bearer is, or null. Constant-time per comparison; fail closed
|
|
7827
7960
|
* on unset/empty secrets. */
|
|
@@ -7830,6 +7963,7 @@ function tokenScope(env: Env, token: string | null): Scope | null {
|
|
|
7830
7963
|
if (env.RESIDENT_ADMIN_TOKEN && timingSafeEqual(token, env.RESIDENT_ADMIN_TOKEN)) return "admin";
|
|
7831
7964
|
if (env.RESIDENT_OPERATOR_TOKEN && timingSafeEqual(token, env.RESIDENT_OPERATOR_TOKEN)) return "operator";
|
|
7832
7965
|
if (env.RESIDENT_READ_TOKEN && timingSafeEqual(token, env.RESIDENT_READ_TOKEN)) return "read";
|
|
7966
|
+
if (env.RESIDENT_DRAIN_TOKEN && timingSafeEqual(token, env.RESIDENT_DRAIN_TOKEN)) return "drain";
|
|
7833
7967
|
return null;
|
|
7834
7968
|
}
|
|
7835
7969
|
|
|
@@ -8009,6 +8143,8 @@ const ROUTES: Record<string, { scope: Scope; method: string }> = {
|
|
|
8009
8143
|
"/offboard": { scope: "admin", method: "POST" },
|
|
8010
8144
|
"/reconfigure": { scope: "admin", method: "POST" },
|
|
8011
8145
|
"/rebuild": { scope: "admin", method: "POST" },
|
|
8146
|
+
"/drain": { scope: "drain", method: "POST" }, // close the fleet to new runs for a deploy (item 69; admin implied)
|
|
8147
|
+
"/undrain": { scope: "drain", method: "POST" }, // reopen it
|
|
8012
8148
|
"/residents": { scope: "read", method: "GET" }, // admin implied; read-only bearer allowed
|
|
8013
8149
|
"/debug": { scope: "read", method: "POST" }, // per-op: READ_DEBUG_OPS for read scope, everything for admin
|
|
8014
8150
|
"/status": { scope: "operator", method: "GET" },
|
|
@@ -8109,6 +8245,10 @@ export default {
|
|
|
8109
8245
|
return await handleReconfigure(env, body);
|
|
8110
8246
|
case "/rebuild":
|
|
8111
8247
|
return await handleRebuild(env, body);
|
|
8248
|
+
case "/drain":
|
|
8249
|
+
return await handleDrain(env, body);
|
|
8250
|
+
case "/undrain":
|
|
8251
|
+
return await handleUndrain(env);
|
|
8112
8252
|
case "/residents":
|
|
8113
8253
|
return await handleResidents(env);
|
|
8114
8254
|
case "/debug": {
|
|
@@ -8239,6 +8379,7 @@ async function handleOnboard(env: Env, body: Record<string, unknown>): Promise<R
|
|
|
8239
8379
|
`exact name (GitHub's token API answers the same 422 for both). An org admin adds it under the ` +
|
|
8240
8380
|
`App's installation settings (Settings → GitHub Apps → Configure → Repository access), ` +
|
|
8241
8381
|
`then retry (${errMsg(err)})`,
|
|
8382
|
+
cause: "policy",
|
|
8242
8383
|
},
|
|
8243
8384
|
403,
|
|
8244
8385
|
);
|
|
@@ -8529,6 +8670,26 @@ async function handleRebuild(env: Env, body: Record<string, unknown>): Promise<R
|
|
|
8529
8670
|
* targets a different DO, so they run concurrently; a failing one degrades
|
|
8530
8671
|
* to {error} without touching its neighbors, and the response order follows
|
|
8531
8672
|
* the registry list. */
|
|
8673
|
+
/** POST /drain (admin): close the fleet to new runs (docs/reference/specs/resident-repos.md
|
|
8674
|
+
* item 69). The record carries its own end (`until`), so a drain nobody lifts
|
|
8675
|
+
* ends by itself; a second drain replaces the first. Runs in flight are
|
|
8676
|
+
* untouched — the deploy that asked waits for them through `/residents`. */
|
|
8677
|
+
async function handleDrain(env: Env, body: Record<string, unknown>): Promise<Response> {
|
|
8678
|
+
const parsed = parseDrainRequest(body, systemClock());
|
|
8679
|
+
if (!parsed.ok) return json({ error: parsed.error }, 400);
|
|
8680
|
+
const record = await registryStub(env).setDrain(parsed.record);
|
|
8681
|
+
console.log(`[drain] fleet closed to new runs by ${record.by} for ${record.reason}: until ${record.until}`);
|
|
8682
|
+
return json({ draining: record });
|
|
8683
|
+
}
|
|
8684
|
+
|
|
8685
|
+
/** POST /undrain (admin): reopen the fleet. Idempotent — `cleared` says whether
|
|
8686
|
+
* a drain stood. */
|
|
8687
|
+
async function handleUndrain(env: Env): Promise<Response> {
|
|
8688
|
+
const cleared = await registryStub(env).clearDrain();
|
|
8689
|
+
console.log(`[drain] fleet reopened (${cleared ? "a drain stood" : "no drain stood"})`);
|
|
8690
|
+
return json({ draining: null, cleared });
|
|
8691
|
+
}
|
|
8692
|
+
|
|
8532
8693
|
async function handleResidents(env: Env): Promise<Response> {
|
|
8533
8694
|
const residents = await registryStub(env).list();
|
|
8534
8695
|
const settled = await Promise.allSettled(
|
|
@@ -8564,6 +8725,9 @@ async function handleResidents(env: Env): Promise<Response> {
|
|
|
8564
8725
|
count: residents.length,
|
|
8565
8726
|
inFlight,
|
|
8566
8727
|
inFlightUnknown,
|
|
8728
|
+
// The drain in force, or null (item 69): the deploy runner and the
|
|
8729
|
+
// dashboard read it here; the attach route reads the same record.
|
|
8730
|
+
draining: liveDrain(await registryStub(env).getDrain(), systemClock()),
|
|
8567
8731
|
residents: enriched,
|
|
8568
8732
|
});
|
|
8569
8733
|
}
|
|
@@ -58,9 +58,14 @@ import {
|
|
|
58
58
|
fleetBusyAnswer,
|
|
59
59
|
fleetBusyExecAnswer,
|
|
60
60
|
isFleetBusyError,
|
|
61
|
+
isRuntimeBusyError,
|
|
62
|
+
isRuntimeBusySignal,
|
|
61
63
|
isRuntimeUnreachableError,
|
|
64
|
+
runtimeBusyAnswer,
|
|
65
|
+
runtimeBusyExecAnswer,
|
|
62
66
|
runtimeUnreachableAnswer,
|
|
63
67
|
runtimeUnreachableExecAnswer,
|
|
68
|
+
SandboxRuntimeBusyError,
|
|
64
69
|
SandboxRuntimeUnreachableError,
|
|
65
70
|
sandboxStartingAnswer,
|
|
66
71
|
sandboxStartingExecAnswer,
|
|
@@ -182,6 +187,16 @@ function isRuntimeUnreachable(err: unknown): boolean {
|
|
|
182
187
|
return false;
|
|
183
188
|
}
|
|
184
189
|
|
|
190
|
+
/** Did the platform refuse the connect inside its own accept allowance
|
|
191
|
+
* (docs/reference/specs/execution.md item 28)? The platform's own wording,
|
|
192
|
+
* anywhere in the cause chain; a plain `Error`, so the wording is all there
|
|
193
|
+
* is — and its words blame load the platform never measured. Asked only of
|
|
194
|
+
* a failure met before a process was started. */
|
|
195
|
+
function isRuntimeBusy(err: unknown): boolean {
|
|
196
|
+
for (const link of selfAndCauses(err)) if (isRuntimeBusySignal(link)) return true;
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
185
200
|
/** A finished command, as `/exec` answers it. `durationMs` is the command's
|
|
186
201
|
* wall time in the sandbox (docs/reference/specs/tracing.md item 19). */
|
|
187
202
|
export interface ExecAnswer {
|
|
@@ -194,7 +209,7 @@ export interface ExecAnswer {
|
|
|
194
209
|
/** A command the sandbox never answered for, in the dual in-body shape
|
|
195
210
|
* (docs/reference/specs/execution.md item 3): a new executor throws on `error`, an
|
|
196
211
|
* older one still renders `exit 127: <stderr>`. `reason` names the machine
|
|
197
|
-
* token when there is one (`fleet-busy`, `runtime-unreachable`). */
|
|
212
|
+
* token when there is one (`fleet-busy`, `runtime-busy`, `runtime-unreachable`). */
|
|
198
213
|
export interface ExecFailure {
|
|
199
214
|
error: string;
|
|
200
215
|
reason?: string;
|
|
@@ -205,8 +220,8 @@ export interface ExecFailure {
|
|
|
205
220
|
|
|
206
221
|
/** A file route's refusal, with the HTTP status the fetch handler answers and,
|
|
207
222
|
* when the refusal is a named condition the executor waits on (`fleet-busy`,
|
|
208
|
-
* `runtime-unreachable`), its machine token — the executor
|
|
209
|
-
* never the text, so a refusal without it is a dead sandbox to it. */
|
|
223
|
+
* `runtime-busy`, `runtime-unreachable`), its machine token — the executor
|
|
224
|
+
* reads the token, never the text, so a refusal without it is a dead sandbox to it. */
|
|
210
225
|
interface FileRefusal {
|
|
211
226
|
error: string;
|
|
212
227
|
status: number;
|
|
@@ -321,9 +336,10 @@ export class SwitchboardSandbox extends Sandbox<Env> {
|
|
|
321
336
|
(cause) => sandboxStartingExecAnswer(cause),
|
|
322
337
|
);
|
|
323
338
|
} catch (err) {
|
|
324
|
-
// The warm-up's own failure, handed on by the gate: a full fleet
|
|
325
|
-
// silent control port keeps its name; anything
|
|
326
|
-
|
|
339
|
+
// The warm-up's own failure, handed on by the gate: a full fleet, a
|
|
340
|
+
// refused connect or a silent control port keeps its name; anything
|
|
341
|
+
// else propagates. Nothing ran — the warm-up is a spawn.
|
|
342
|
+
return this.spawnFailure(err, startedAt);
|
|
327
343
|
}
|
|
328
344
|
});
|
|
329
345
|
}
|
|
@@ -343,7 +359,10 @@ export class SwitchboardSandbox extends Sandbox<Env> {
|
|
|
343
359
|
try {
|
|
344
360
|
proc = await createExtensionProcessSandbox(this).exec(argv, { env: envVars, timeout: backstopMs });
|
|
345
361
|
} catch (err) {
|
|
346
|
-
|
|
362
|
+
// The process was never started: a refused connect is the wait token
|
|
363
|
+
// here and only here (item 28) — the executor re-sends, and
|
|
364
|
+
// nothing runs twice.
|
|
365
|
+
return this.spawnFailure(err, startedAt);
|
|
347
366
|
}
|
|
348
367
|
try {
|
|
349
368
|
const out = await proc.output({
|
|
@@ -631,6 +650,20 @@ export class SwitchboardSandbox extends Sandbox<Env> {
|
|
|
631
650
|
return { stdout: out.stdout, stderr: out.stderr, exitCode: out.timedOut ? 124 : out.exitCode };
|
|
632
651
|
}
|
|
633
652
|
|
|
653
|
+
/** A failure met BEFORE a process was started — the spawn, or the gate's
|
|
654
|
+
* warm-up: a container that did not accept the connection is named with
|
|
655
|
+
* its wait token (docs/reference/specs/execution.md item 28), since
|
|
656
|
+
* nothing ran and the identical request is safe to re-send. Every other
|
|
657
|
+
* failure is classified as after a start (`execFailure`). A failure of a
|
|
658
|
+
* running command's output never comes here: the process exists, and a
|
|
659
|
+
* re-send would run it again. */
|
|
660
|
+
private spawnFailure(err: unknown, startedAt: number): ExecFailure {
|
|
661
|
+
if (!isFleetBusyError(err) && !isRuntimeReplacement(err) && isRuntimeBusy(err)) {
|
|
662
|
+
return runtimeBusyExecAnswer(this.runtimeBusy(thrownText(thrownShape(err))).message);
|
|
663
|
+
}
|
|
664
|
+
return this.execFailure(err, startedAt);
|
|
665
|
+
}
|
|
666
|
+
|
|
634
667
|
/** The named failures, as `/exec` data; anything else is thrown as it came. */
|
|
635
668
|
private execFailure(err: unknown, startedAt: number): ExecFailure {
|
|
636
669
|
const raw = thrownText(thrownShape(err));
|
|
@@ -660,15 +693,25 @@ export class SwitchboardSandbox extends Sandbox<Env> {
|
|
|
660
693
|
});
|
|
661
694
|
}
|
|
662
695
|
|
|
696
|
+
/** The typed, named error for a container that did not accept the
|
|
697
|
+
* connection (item 28), with this container's id — thrown across the RPC
|
|
698
|
+
* boundary to the fetch handler on the file routes, matched by name. */
|
|
699
|
+
private runtimeBusy(cause: string): SandboxRuntimeBusyError {
|
|
700
|
+
return new SandboxRuntimeBusyError({ containerId: this.ctx.id.toString(), cause });
|
|
701
|
+
}
|
|
702
|
+
|
|
663
703
|
/** A file operation with its runtime failures named for the fetch handler:
|
|
664
|
-
* a
|
|
665
|
-
* is the refusal the route answers 404.
|
|
704
|
+
* a refused connect (item 28) and a silent control port (item 9) become
|
|
705
|
+
* the typed errors; a missing file is the refusal the route answers 404.
|
|
706
|
+
* The SDK's other errors propagate. A file operation that met either
|
|
707
|
+
* never reached the runtime, so the executor's re-send does nothing twice. */
|
|
666
708
|
private async fileOp<T>(op: () => Promise<T>): Promise<T | FileRefusal> {
|
|
667
709
|
try {
|
|
668
710
|
return await op();
|
|
669
711
|
} catch (err) {
|
|
670
712
|
const shape = thrownShape(err);
|
|
671
713
|
if (shape.name === "FileNotFoundError") return { error: `read-failed: ${thrownText(shape)}`, status: 404 };
|
|
714
|
+
if (!isRuntimeReplacement(err) && isRuntimeBusy(err)) throw this.runtimeBusy(thrownText(shape));
|
|
672
715
|
if (!isRuntimeReplacement(err) && isRuntimeUnreachable(err)) throw this.runtimeUnreachable(thrownText(shape));
|
|
673
716
|
throw err;
|
|
674
717
|
}
|
|
@@ -884,6 +927,9 @@ export default {
|
|
|
884
927
|
// op never reached a runtime, so a 503 the executor's transport retry
|
|
885
928
|
// re-sends, with the named reason and the container in the text.
|
|
886
929
|
if (isRuntimeUnreachableError(err)) return json(runtimeUnreachableAnswer(msg), 503);
|
|
930
|
+
// The container did not accept the connection (item 28): the file op
|
|
931
|
+
// never reached it either — a 503 with the wait token.
|
|
932
|
+
if (isRuntimeBusyError(err)) return json(runtimeBusyAnswer(msg), 503);
|
|
887
933
|
// A full fleet (docs/reference/specs/execution.md item 14): no container
|
|
888
934
|
// instance for this thread's Durable Object, so the file op never
|
|
889
935
|
// started — re-sending is safe by construction. Named so the executor
|
|
@@ -972,6 +1018,7 @@ function streamExec(run: () => Promise<ExecAnswer | ExecFailure>, traceparent: s
|
|
|
972
1018
|
const shape = thrownShape(err);
|
|
973
1019
|
const raw = thrownText(shape);
|
|
974
1020
|
if (isRuntimeUnreachableError(err)) return runtimeUnreachableExecAnswer(raw);
|
|
1021
|
+
if (isRuntimeBusyError(err)) return runtimeBusyExecAnswer(raw);
|
|
975
1022
|
if (isFleetBusyError(err)) return fleetBusyExecAnswer(raw);
|
|
976
1023
|
// A recycle the Durable Object did not catch by type: by name, or
|
|
977
1024
|
// a recycle-shaped text minutes into the attempt (item 9).
|
|
@@ -81,6 +81,12 @@
|
|
|
81
81
|
"workers": ["resident"],
|
|
82
82
|
"note": "Read-only /residents + debug routes; what the resident deploy preflight needs (CI holds this one). Self-minted."
|
|
83
83
|
},
|
|
84
|
+
{
|
|
85
|
+
"name": "RESIDENT_DRAIN_TOKEN",
|
|
86
|
+
"workers": ["resident"],
|
|
87
|
+
"optional": true,
|
|
88
|
+
"note": "Drain-only bearer: POST /drain and /undrain, nothing else (docs/reference/specs/resident-repos.md item 69). What the release deploy holds (CI) so the resident step can close the fleet to new runs and land once the runs in flight end, without the admin bearer. Self-minted; optional — unset, only admin can drain and the deploy waits without one."
|
|
89
|
+
},
|
|
84
90
|
{
|
|
85
91
|
"name": "R2_ACCESS_KEY_ID",
|
|
86
92
|
"workers": ["resident", "sandbox"],
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.249.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.249.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -20445,7 +20445,7 @@
|
|
|
20445
20445
|
},
|
|
20446
20446
|
"packages/switchboard": {
|
|
20447
20447
|
"name": "@coreplane/switchboard",
|
|
20448
|
-
"version": "1.
|
|
20448
|
+
"version": "1.249.0",
|
|
20449
20449
|
"license": "Apache-2.0",
|
|
20450
20450
|
"dependencies": {
|
|
20451
20451
|
"@earendil-works/pi-ai": "0.85.1",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.249.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
|
|
6
6
|
"license": "Apache-2.0",
|
package/dist/assets/source.json
CHANGED
|
@@ -188,7 +188,7 @@ const UNIT_CONTRACT = `UNIT CONTRACT: when your first user turn carries a \`${CO
|
|
|
188
188
|
// it to the unit's board issue without a person writing it there. Both coding
|
|
189
189
|
// prompts carry this verbatim, right after the contract paragraph, so the
|
|
190
190
|
// sandbox and resident children read the same rule.
|
|
191
|
-
const UNIT_HANDOFF = `UNIT HANDOFF: when your first user turn carries a \`${CONTRACT_HEADING}\` block, call the submit_handoff tool once, after submit_pr_description and before your final message, with the typed handoff — deviations: where you departed from the unit as written (from, to, why); followUps: what you found and did not do, and where it belongs (what, where); unproven: which of the unit's test scenarios or criteria you could not prove, and why (criterion, why). Switchboard records it on the run and posts it to the unit's board issue, where a person decides each row's disposition; you never edit the plan's ledger yourself. An empty handoff is submitted as three empty lists, never skipped — a missing handoff reads as an unfinished run, not as nothing to say. Without a \`${CONTRACT_HEADING}\` block, do not call it.`;
|
|
191
|
+
const UNIT_HANDOFF = `UNIT HANDOFF: when your first user turn carries a \`${CONTRACT_HEADING}\` block, call the submit_handoff tool once, after submit_pr_description and before your final message, with the typed handoff — deviations: where you departed from the unit as written (from, to, why); followUps: what you found and did not do, and where it belongs (what, where); unproven: which of the unit's test scenarios or criteria you could not prove, and why (criterion, why); landed (optional): what of the unit was already on the base when you began, and the pull request or commit that carries it (what, where) — when the whole unit is already there, push nothing of your own and open no pull request; the handoff's landed rows end the unit done. Switchboard records it on the run and posts it to the unit's board issue, where a person decides each row's disposition; you never edit the plan's ledger yourself. An empty handoff is submitted as three empty lists, never skipped — a missing handoff reads as an unfinished run, not as nothing to say. Without a \`${CONTRACT_HEADING}\` block, do not call it.`;
|
|
192
192
|
|
|
193
193
|
// What both execution images carry beyond git and the package managers
|
|
194
194
|
// (docs/reference/specs/execution.md item 10), said in one sentence by every
|
|
@@ -544,7 +544,7 @@ Your tools work without a workspace: the GitHub tools — \`github_repos\` (the
|
|
|
544
544
|
|
|
545
545
|
${statusCardRule('"Read the issue and its thread", "Post the comment"')} A one-step answer needs no checklist; post one when the request has steps the person would wait on.
|
|
546
546
|
|
|
547
|
-
You cannot run commands, clone repositories, edit code, or review pull requests, and you cannot search the web. Other Switchboard agents can: for a code change or a pull request
|
|
547
|
+
You cannot run commands, clone repositories, edit code, or review pull requests, and you cannot search the web. Other Switchboard agents can, and a plain message reaches them by itself: for a code change or a pull request, say what you found and that the change is not yours to make, and that asking for it in plain words in a new message — "in acme/api: fix the failing login test" — starts the agent that makes the change, opens the PR and loops review; the same for a PR review ("review <PR URL>") and a web-research question ("compare X and Y on the web"). Never hand back a command or an \`agent:…\` line for the person to type: describe the ask in their words. Delete an issue only when the user explicitly asked to delete it (closing is an update).`;
|
|
548
548
|
|
|
549
549
|
// The explore agent (docs/reference/specs/agent-explore.md): a long, read-only
|
|
550
550
|
// investigation — "run our CI locally and validate the claims", "how long does
|
|
@@ -567,9 +567,9 @@ THE DELIVERABLE IS A CLAIM TABLE. Turn the request into the claims it makes or a
|
|
|
567
567
|
|
|
568
568
|
TIME. Your budget is up to two hours — less when a boundary or the request's \`budget:\` directive clipped it, which the runtime-config block above says — and the wrap-up warning tells you when to stop starting new checks. A single command is capped at ${BASH_TIMEOUT_MAX_MS / 60_000} minutes (pass the bash tool's \`timeoutMs\`, up to ${BASH_TIMEOUT_MAX_MS} ms, for a long one). A job that needs longer — a full suite, a build, a pipeline run — is started detached and polled across tool calls: \`setsid -f sh -c '<command> > /tmp/job.log 2>&1; echo $? > /tmp/job.exit'\`, then \`tail -n 40 /tmp/job.log\` and \`cat /tmp/job.exit\` on later calls (a plain background job dies with the command that started it; a \`setsid -f\` job outlives it). Batch commands into few tool calls; never explore file by file.
|
|
569
569
|
|
|
570
|
-
READ-ONLY: NEVER open a pull request, and never commit or push — no branch, no \`gh pr create\`, no PR or issue write of any kind. You hold a read credential and your job is to find out, not to change. If the investigation shows a change is needed, say exactly what and where in your write-up and
|
|
570
|
+
READ-ONLY: NEVER open a pull request, and never commit or push — no branch, no \`gh pr create\`, no PR or issue write of any kind. You hold a read credential and your job is to find out, not to change. If the investigation shows a change is needed, say exactly what and where in your write-up, and that asking for it in plain words in a new message ("in <owner/name>: <the change>") starts the agent that makes the change, opens the PR and loops review — never hand back a command or an \`agent:…\` line to type.
|
|
571
571
|
|
|
572
|
-
You cannot attach or post files: your whole answer is text. Never say a file is attached or below — name its path in the workspace and describe it (what it shows, its size) instead; a person who needs the file itself asks
|
|
572
|
+
You cannot attach or post files: your whole answer is text. Never say a file is attached or below — name its path in the workspace and describe it (what it shows, its size) instead; a person who needs the file itself asks for it to be attached in a new message, which reaches a preset that can.
|
|
573
573
|
|
|
574
574
|
${statusCardRule('"Clone and install", "Time the full suite"')}
|
|
575
575
|
|