@coreplane/switchboard 1.267.0 → 1.267.1
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/deploy/cloudflare-resident/gc.ts +1 -0
- package/dist/assets/deploy/cloudflare-resident/keyedAsyncLock.ts +20 -0
- package/dist/assets/deploy/cloudflare-resident/legacyCredentials.ts +57 -0
- package/dist/assets/deploy/cloudflare-resident/orphanThreadUsers.ts +14 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +646 -204
- 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/core/budgets.ts +10 -0
- package/dist/assets/src/core/coordinator/contract.ts +29 -0
- package/dist/assets/src/core/coordinator/driver.ts +15 -2
- package/dist/assets/src/core/runRecord.ts +32 -0
- package/dist/assets/src/core/ship/coordinator.ts +42 -4
- package/dist/assets/src/execution/githubMintScope.ts +49 -0
- package/dist/assets/src/execution/residentCredentials.ts +9 -37
- package/dist/assets/src/execution/residentDestroyGate.ts +12 -0
- package/dist/assets/src/execution/residentPoolSpends.ts +96 -0
- package/dist/assets/src/execution/residentRecreateAdmission.ts +41 -0
- package/dist/assets/src/execution/residentSteps.ts +1 -0
- package/dist/assets/web/dist/.vite/manifest.json +67 -67
- package/dist/assets/web/dist/assets/{DeliveryPage-BUJckSuW.js → DeliveryPage-QyS6_Q0m.js} +1 -1
- package/dist/assets/web/dist/assets/{HomePage-BfpIrKDX.js → HomePage-Bm40r0wF.js} +1 -1
- package/dist/assets/web/dist/assets/{PendingTurnRow-C6hroa00.js → PendingTurnRow-BqY-EaZR.js} +1 -1
- package/dist/assets/web/dist/assets/{PlanePage-C2Cb1mkT.js → PlanePage-VmzjNLAz.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-Bd9_Q87l.js → ResidentDetailPage-hjX3tRCh.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-C8eS7hxp.js → ResidentsIndexPage-BKGnlCEh.js} +1 -1
- package/dist/assets/web/dist/assets/{RunFoldRow-DrfFauYJ.js → RunFoldRow-BOWvSoIv.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-B_eXfNAF.js → RunRoutePage-D9ztf-uE.js} +3 -3
- package/dist/assets/web/dist/assets/{RunsIndexPage-BpQu36Dm.js → RunsIndexPage-DTYQbhtr.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-DGHcjcSt.js → ScheduledPage-BAYR0tuv.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-IfQ71QjN.js → SettingsPage-BlYBmCG9.js} +1 -1
- package/dist/assets/web/dist/assets/{SilentTurn-D9ON15a8.js → SilentTurn-CHfzRQW8.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-DyFjGg4V.js → StatusDot-BMkUFVE8.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-BvPkV4kZ.js → Tooltip-Dfot16IA.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-BNe3nBIG.js → UnitRoutePage-BdZoT-4d.js} +1 -1
- package/dist/assets/web/dist/assets/budgets-CJHiTZmM.js +1 -0
- package/dist/assets/web/dist/assets/{dist-v3Py3_QR.js → dist-XRrc0hhm.js} +1 -1
- package/dist/assets/web/dist/assets/{indexRow-BsWcHHVh.js → indexRow-BGjjLnN5.js} +1 -1
- package/dist/assets/web/dist/assets/{main-ks3kBojV.js → main-B32N1vUh.js} +2 -2
- package/dist/assets/web/dist/assets/{sseReplay-DuALaBPq.js → sseReplay-BsNQPLmJ.js} +2 -2
- package/dist/cli.js +928 -346
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/budgets-BQgcCyvb.js +0 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.267.
|
|
3
|
+
"version": "1.267.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.267.
|
|
9
|
+
"version": "1.267.1",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -21023,7 +21023,7 @@
|
|
|
21023
21023
|
},
|
|
21024
21024
|
"packages/switchboard": {
|
|
21025
21025
|
"name": "@coreplane/switchboard",
|
|
21026
|
-
"version": "1.267.
|
|
21026
|
+
"version": "1.267.1",
|
|
21027
21027
|
"license": "Apache-2.0",
|
|
21028
21028
|
"dependencies": {
|
|
21029
21029
|
"@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.267.
|
|
3
|
+
"version": "1.267.1",
|
|
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
|
@@ -64,6 +64,16 @@ export const QUESTION_TTL_MS = DAY_MS;
|
|
|
64
64
|
* that FAILED is never cached — the next read asks again. */
|
|
65
65
|
export const AUTHOR_BINDING_TTL_MS = 15 * MINUTE_MS;
|
|
66
66
|
|
|
67
|
+
/** Maximum local lifetime of any Git receive-pack forward. The bounded
|
|
68
|
+
* request prevents a write connection outliving its run indefinitely after
|
|
69
|
+
* revocation. An aborted remote write has an unknown outcome; existing-PR
|
|
70
|
+
* writes retain their owned pending intent for authoritative reconciliation. */
|
|
71
|
+
export const GIT_RECEIVE_PACK_FORWARD_TIMEOUT_MS = 5 * MINUTE_MS;
|
|
72
|
+
/** The run's final record waits for that one forwarded report, plus a small
|
|
73
|
+
* durable-receipt margin, before sealing. If it still has no answer, the
|
|
74
|
+
* pending intent stays as an uncertain write and no retry is authorized. */
|
|
75
|
+
export const GIT_PUBLICATION_SETTLE_TIMEOUT_MS = GIT_RECEIVE_PACK_FORWARD_TIMEOUT_MS + 10 * SECOND_MS;
|
|
76
|
+
|
|
67
77
|
/** How long a dispatch's FIRST attach to a resident — a fresh run's, or a
|
|
68
78
|
* resumed run's re-attach to its recorded worktree — waits for the resident
|
|
69
79
|
* to wake when the Worker typed its refusal as the platform's transient
|
|
@@ -493,6 +493,15 @@ export interface RoundGate {
|
|
|
493
493
|
findings: string[];
|
|
494
494
|
}
|
|
495
495
|
|
|
496
|
+
/** A later human review is evidence to investigate, never typed findings. */
|
|
497
|
+
export interface RecoveryReviewEvidence {
|
|
498
|
+
id: number;
|
|
499
|
+
reviewer: { login: string; id: number };
|
|
500
|
+
headSha: string;
|
|
501
|
+
submittedAt: number;
|
|
502
|
+
body: string;
|
|
503
|
+
}
|
|
504
|
+
|
|
496
505
|
/** The original grant and historically priced children carried through a recovery checkpoint. */
|
|
497
506
|
export interface RecoveryAccounting {
|
|
498
507
|
spendUsd: number;
|
|
@@ -506,6 +515,7 @@ export interface RecoveryAccounting {
|
|
|
506
515
|
* the original instance/unit idempotency namespace. */
|
|
507
516
|
export interface OriginalUnitRecovery {
|
|
508
517
|
kind: "findings" | "review";
|
|
518
|
+
externalReview?: RecoveryReviewEvidence;
|
|
509
519
|
accounting?: RecoveryAccounting;
|
|
510
520
|
round: number;
|
|
511
521
|
expectedHeadSha: string;
|
|
@@ -540,6 +550,7 @@ export interface OriginalUnitRecovery {
|
|
|
540
550
|
}
|
|
541
551
|
|
|
542
552
|
export interface OriginalUnitRecoveryReceipt {
|
|
553
|
+
externalReview?: RecoveryReviewEvidence;
|
|
543
554
|
accounting?: RecoveryAccounting;
|
|
544
555
|
reviewRunId: string;
|
|
545
556
|
workflowId: string;
|
|
@@ -767,7 +778,18 @@ export const isUnitWakeAnswer = (v: unknown): v is UnitWakeAnswer => {
|
|
|
767
778
|
);
|
|
768
779
|
};
|
|
769
780
|
|
|
781
|
+
const isPositiveId = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v > 0;
|
|
770
782
|
const isDollars = (v: unknown): v is number => isFinite(v) && v >= 0;
|
|
783
|
+
const isRecoveryReview = (v: unknown): v is RecoveryReviewEvidence =>
|
|
784
|
+
isObject(v) &&
|
|
785
|
+
isPositiveId(v.id) &&
|
|
786
|
+
isObject(v.reviewer) &&
|
|
787
|
+
isText(v.reviewer.login) &&
|
|
788
|
+
isPositiveId(v.reviewer.id) &&
|
|
789
|
+
typeof v.headSha === "string" &&
|
|
790
|
+
/^[0-9a-f]{40}$/i.test(v.headSha) &&
|
|
791
|
+
isFinite(v.submittedAt) &&
|
|
792
|
+
typeof v.body === "string";
|
|
771
793
|
const isRecoveryAccounting = (v: unknown): v is RecoveryAccounting =>
|
|
772
794
|
isObject(v) &&
|
|
773
795
|
isDollars(v.spendUsd) &&
|
|
@@ -839,6 +861,12 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
|
|
|
839
861
|
STEP_NAME_PATTERN.test(r.recovery.step) &&
|
|
840
862
|
isText(r.recovery.reviewRunId) &&
|
|
841
863
|
(r.recovery.accounting === undefined || isRecoveryAccounting(r.recovery.accounting)) &&
|
|
864
|
+
(r.recovery.externalReview === undefined ||
|
|
865
|
+
(isRecoveryReview(r.recovery.externalReview) &&
|
|
866
|
+
r.recovery.kind === "review" &&
|
|
867
|
+
isRecoveryAccounting(r.recovery.accounting) &&
|
|
868
|
+
r.recovery.findings === undefined &&
|
|
869
|
+
r.recovery.findingsRunId === undefined)) &&
|
|
842
870
|
(r.recovery.findingsRunId === undefined || isText(r.recovery.findingsRunId)) &&
|
|
843
871
|
(r.recovery.findingsKey === undefined || isText(r.recovery.findingsKey)) &&
|
|
844
872
|
((r.recovery.findingsRunId === undefined && r.recovery.findingsKey === undefined) ||
|
|
@@ -869,6 +897,7 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
|
|
|
869
897
|
r.recoveryReceipt !== undefined &&
|
|
870
898
|
(!isObject(r.recoveryReceipt) ||
|
|
871
899
|
!isText(r.recoveryReceipt.reviewRunId) ||
|
|
900
|
+
(r.recoveryReceipt.externalReview !== undefined && !isRecoveryReview(r.recoveryReceipt.externalReview)) ||
|
|
872
901
|
(r.recoveryReceipt.accounting !== undefined && !isRecoveryAccounting(r.recoveryReceipt.accounting)) ||
|
|
873
902
|
typeof r.recoveryReceipt.workflowId !== "string" ||
|
|
874
903
|
!INSTANCE_ID_PATTERN.test(r.recoveryReceipt.workflowId) ||
|
|
@@ -687,6 +687,18 @@ function isOpaqueNotFound(reply: BotReply): boolean {
|
|
|
687
687
|
}
|
|
688
688
|
}
|
|
689
689
|
|
|
690
|
+
/** A finished, addressed child whose final record is still being sealed.
|
|
691
|
+
* Unlike an opaque 404, this answer already establishes the child's identity. */
|
|
692
|
+
function isRecordPending(reply: BotReply): boolean {
|
|
693
|
+
if (reply.status !== 409) return false;
|
|
694
|
+
try {
|
|
695
|
+
const body = JSON.parse(reply.text) as { ok?: unknown; error?: unknown };
|
|
696
|
+
return body?.ok === false && body.error === "record_pending";
|
|
697
|
+
} catch {
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
690
702
|
async function readRecordStep(
|
|
691
703
|
step: StepRunner,
|
|
692
704
|
bot: CoordinatorBot,
|
|
@@ -696,8 +708,9 @@ async function readRecordStep(
|
|
|
696
708
|
for (let retry = 0; ; retry++) {
|
|
697
709
|
const name = retry === 0 ? action.step : `${action.step}/record-read/${retry}`;
|
|
698
710
|
const reply = await step.do(name, STEP_CONFIG, () => call(bot, "read-record", body, true));
|
|
699
|
-
|
|
700
|
-
|
|
711
|
+
const pending = isRecordPending(reply);
|
|
712
|
+
const eventQualifiedMissing = action.finishedObserved === true && isOpaqueNotFound(reply);
|
|
713
|
+
if ((!pending && !eventQualifiedMissing) || retry >= SHIP_RECORD_VISIBILITY.retries) return reply;
|
|
701
714
|
await step.sleep(`${action.step}/record-visible/${retry + 1}`, SHIP_RECORD_VISIBILITY.retryMs);
|
|
702
715
|
}
|
|
703
716
|
}
|
|
@@ -279,6 +279,17 @@ export interface RunRecord {
|
|
|
279
279
|
* last `pushed_head` event's sha winning — the fact a renewal reads to know
|
|
280
280
|
* the run made progress. Present only on a run that pushed. */
|
|
281
281
|
pushed?: PushedHead[];
|
|
282
|
+
/** A Git-door write whose remote outcome was still uncertain at seal. The
|
|
283
|
+
* live ledger row is deleted at finish, so recovery retains this exact
|
|
284
|
+
* ref transition until head reconciliation. A PR number exists only for a
|
|
285
|
+
* verified existing-PR publication. */
|
|
286
|
+
doorPublicationPending?: {
|
|
287
|
+
id: string;
|
|
288
|
+
repo: string;
|
|
289
|
+
pr?: number;
|
|
290
|
+
owner?: { instanceId: string; unit: string };
|
|
291
|
+
update: { ref: string; old: string; next: string };
|
|
292
|
+
};
|
|
282
293
|
/** What the run cost in tokens, per model, summed from its `model.turn`
|
|
283
294
|
* spans at finish (`usageOfEvents`; docs/reference/specs/costs.md, cost by user).
|
|
284
295
|
* Every record written since carries it (zero turns included); one written
|
|
@@ -1124,6 +1135,27 @@ export function isRunRecord(v: unknown): v is RunRecord {
|
|
|
1124
1135
|
)
|
|
1125
1136
|
)
|
|
1126
1137
|
return false;
|
|
1138
|
+
if (r.doorPublicationPending !== undefined) {
|
|
1139
|
+
const pending = r.doorPublicationPending;
|
|
1140
|
+
if (typeof pending !== "object" || pending === null) return false;
|
|
1141
|
+
const { id, repo, pr, owner, update } = pending as Record<string, unknown>;
|
|
1142
|
+
if (
|
|
1143
|
+
typeof id !== "string" ||
|
|
1144
|
+
!id ||
|
|
1145
|
+
typeof repo !== "string" ||
|
|
1146
|
+
!repo ||
|
|
1147
|
+
(pr !== undefined && (!Number.isInteger(pr) || (pr as number) < 1))
|
|
1148
|
+
)
|
|
1149
|
+
return false;
|
|
1150
|
+
if (owner !== undefined && (typeof owner !== "object" || owner === null)) return false;
|
|
1151
|
+
if (typeof update !== "object" || update === null) return false;
|
|
1152
|
+
const o = owner as Record<string, unknown> | undefined;
|
|
1153
|
+
const u = update as Record<string, unknown>;
|
|
1154
|
+
if (o !== undefined && (typeof o.instanceId !== "string" || typeof o.unit !== "string")) return false;
|
|
1155
|
+
if (typeof u.ref !== "string" || !u.ref.startsWith("refs/heads/")) return false;
|
|
1156
|
+
if (typeof u.old !== "string" || !/^[0-9a-f]{40}$/.test(u.old)) return false;
|
|
1157
|
+
if (typeof u.next !== "string" || !/^[0-9a-f]{40}$/.test(u.next)) return false;
|
|
1158
|
+
}
|
|
1127
1159
|
// A parent is named by a run id (item 46): the same shape as the record's own.
|
|
1128
1160
|
if (r.parentRunId !== undefined && (typeof r.parentRunId !== "string" || !RUN_ID_PATTERN.test(r.parentRunId)))
|
|
1129
1161
|
return false;
|
|
@@ -1243,6 +1243,9 @@ type Phase =
|
|
|
1243
1243
|
* must independently prove an open branch and pull request at exactly
|
|
1244
1244
|
* `childHead`; neither a push record nor this flag is remote evidence. */
|
|
1245
1245
|
findingsReady?: true;
|
|
1246
|
+
/** A capped recovery must verify this pushed head before ending, but
|
|
1247
|
+
* missing findings outputs still forbid review or merge-ready success. */
|
|
1248
|
+
findingsIncomplete?: string[];
|
|
1246
1249
|
/** The coding child died (`failed` or `interrupted`) after it may have
|
|
1247
1250
|
* pushed: the pr-check recovers a pushed branch by opening its pull
|
|
1248
1251
|
* request; with nothing pushed the unit ends with the child's own reason. */
|
|
@@ -1648,7 +1651,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
|
|
|
1648
1651
|
return {
|
|
1649
1652
|
type: "pr-check",
|
|
1650
1653
|
step: `${roundStep(s, p.round)}/pr-check`,
|
|
1651
|
-
...(p.dead !== undefined || p.recover === true || p.findingsReady === true
|
|
1654
|
+
...(p.dead !== undefined || p.recover === true || p.findingsReady === true || p.findingsIncomplete !== undefined
|
|
1652
1655
|
? { recover: { runId: p.runId } }
|
|
1653
1656
|
: {}),
|
|
1654
1657
|
// The adopted pull request rides the check so the bot can follow it
|
|
@@ -1884,7 +1887,8 @@ function capEnding(s: UnitPipelineState, round?: RoundRef, refused?: Carve): Uni
|
|
|
1884
1887
|
};
|
|
1885
1888
|
}
|
|
1886
1889
|
|
|
1887
|
-
/**
|
|
1890
|
+
/** Recovery cannot buy another child (or claim readiness) after the original
|
|
1891
|
+
* dollar cap is spent. Unknown cost is not zero and never resets that cap. */
|
|
1888
1892
|
function recoveryCostEnding(s: UnitPipelineState): UnitEnding | undefined {
|
|
1889
1893
|
const cap = s.input.grant?.costCapUsd;
|
|
1890
1894
|
if (s.input.recovery === undefined || cap === undefined) return undefined;
|
|
@@ -2037,6 +2041,21 @@ function settleCoding(
|
|
|
2037
2041
|
if (facts.description !== true) missingOutputs.push("missing updated pull request description");
|
|
2038
2042
|
const observedHead = fullHead(facts.headSha);
|
|
2039
2043
|
if (observedHead === undefined) missingOutputs.push("missing final observed commit");
|
|
2044
|
+
if (
|
|
2045
|
+
missingOutputs.length > 0 &&
|
|
2046
|
+
costEnding !== undefined &&
|
|
2047
|
+
observedHead !== undefined &&
|
|
2048
|
+
facts.pushed?.length === 1 &&
|
|
2049
|
+
facts.pushed[0]?.ref === next.input.unit.branch &&
|
|
2050
|
+
facts.pushed[0]?.sha === observedHead
|
|
2051
|
+
)
|
|
2052
|
+
return {
|
|
2053
|
+
state: {
|
|
2054
|
+
...next,
|
|
2055
|
+
phase: { at: "pr-check", round, runId, childHead: observedHead, findingsIncomplete: missingOutputs },
|
|
2056
|
+
},
|
|
2057
|
+
notes: [],
|
|
2058
|
+
};
|
|
2040
2059
|
if (missingOutputs.length > 0)
|
|
2041
2060
|
return end(
|
|
2042
2061
|
next,
|
|
@@ -2656,7 +2675,7 @@ function finishSupersededChild(
|
|
|
2656
2675
|
if (phase.round.kind === "findings") {
|
|
2657
2676
|
const settled = settleCoding(s, phase.round, phase.runId, facts);
|
|
2658
2677
|
const ready = settled.state.phase;
|
|
2659
|
-
if (ready.at === "pr-check" && ready.findingsReady === true)
|
|
2678
|
+
if (ready.at === "pr-check" && (ready.findingsReady === true || ready.findingsIncomplete !== undefined))
|
|
2660
2679
|
return settlePrCheck(settled.state, ready, supersession.pullRequest);
|
|
2661
2680
|
return settled;
|
|
2662
2681
|
}
|
|
@@ -2709,11 +2728,24 @@ export function reconcileTerminalPr(s: UnitPipelineState, pr: PrCheck): UnitPipe
|
|
|
2709
2728
|
/** The pull request heading the branch after round 0 or a findings step. */
|
|
2710
2729
|
function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-check" }>, pr: PrCheck): Transition {
|
|
2711
2730
|
const { round } = phase;
|
|
2731
|
+
const endIncompleteFindings = (verified: UnitPipelineState): Transition => {
|
|
2732
|
+
const cap = recoveryCostEnding(verified);
|
|
2733
|
+
return end(
|
|
2734
|
+
verified,
|
|
2735
|
+
{
|
|
2736
|
+
kind: "aborted",
|
|
2737
|
+
reason: `${cap?.kind === "aborted" ? `${cap.reason} ` : ""}The completed findings work still lacks required results (${phase.findingsIncomplete?.join("; ")}); no review started.`,
|
|
2738
|
+
round,
|
|
2739
|
+
reviewRounds: verified.reviewRounds,
|
|
2740
|
+
},
|
|
2741
|
+
[roundNote(round, "aborted")],
|
|
2742
|
+
);
|
|
2743
|
+
};
|
|
2712
2744
|
// A completed findings run is not ready because it says it pushed. It is
|
|
2713
2745
|
// ready only when this independent read sees the adopted pull request at the
|
|
2714
2746
|
// exact full observed source head (and, while open, its live branch). Every
|
|
2715
2747
|
// other remote fact is a resumable stop with no review or merged success.
|
|
2716
|
-
if (phase.findingsReady === true) {
|
|
2748
|
+
if (phase.findingsReady === true || phase.findingsIncomplete !== undefined) {
|
|
2717
2749
|
const observedHead = fullHead(phase.childHead)!;
|
|
2718
2750
|
const withPr = pr.state !== "none" ? { ...s, pr: { number: pr.prNumber, url: pr.url } } : s;
|
|
2719
2751
|
if (pr.state === "merged") {
|
|
@@ -2745,6 +2777,8 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
|
|
|
2745
2777
|
},
|
|
2746
2778
|
[roundNote(round, "aborted")],
|
|
2747
2779
|
);
|
|
2780
|
+
if (phase.findingsIncomplete !== undefined)
|
|
2781
|
+
return endIncompleteFindings({ ...withPr, lastReviewHead: remoteHead });
|
|
2748
2782
|
return foundMerged(withPr, pr, [roundNote(round, "completed")]);
|
|
2749
2783
|
}
|
|
2750
2784
|
if (pr.state !== "open" || pr.headBranchExists === false)
|
|
@@ -2788,6 +2822,10 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
|
|
|
2788
2822
|
},
|
|
2789
2823
|
[roundNote(round, "aborted")],
|
|
2790
2824
|
);
|
|
2825
|
+
const reconciled = { ...withPr, lastReviewHead: remoteHead };
|
|
2826
|
+
if (phase.findingsIncomplete !== undefined) return endIncompleteFindings(reconciled);
|
|
2827
|
+
const costEnding = recoveryCostEnding(reconciled);
|
|
2828
|
+
if (costEnding !== undefined) return end(reconciled, costEnding, [roundNote(round, "aborted")]);
|
|
2791
2829
|
return roundOnOpenPr(withPr, phase, pr);
|
|
2792
2830
|
}
|
|
2793
2831
|
// The merge landed during the round: the child found nothing left to ship
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Permissions for the trusted Git door's receive-pack operation. GitHub
|
|
2
|
+
* also requires Workflows when a pushed commit edits .github/workflows. */
|
|
3
|
+
export const DOOR_PUSH_PERMISSIONS = {
|
|
4
|
+
contents: "write",
|
|
5
|
+
workflows: "write",
|
|
6
|
+
metadata: "read",
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
/** The resident's root-owned token only clones/fetches its mirror and reads
|
|
10
|
+
* pull request state for worktree reclamation. */
|
|
11
|
+
export const RESIDENT_MIRROR_PERMISSIONS = {
|
|
12
|
+
contents: "read",
|
|
13
|
+
pull_requests: "read",
|
|
14
|
+
metadata: "read",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
type Permission = "read" | "write";
|
|
18
|
+
|
|
19
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Check GitHub's effective scope before a token enters either cache. GitHub
|
|
24
|
+
* may omit its implicit Metadata read grant from the response. Errors contain
|
|
25
|
+
* only a fixed reason; they never serialize a response or bearer. */
|
|
26
|
+
export function verifyGithubMintScope(
|
|
27
|
+
response: unknown,
|
|
28
|
+
repo: string,
|
|
29
|
+
requested: Readonly<Record<string, Permission>>,
|
|
30
|
+
): void {
|
|
31
|
+
if (!record(response) || !record(response.permissions)) throw new Error("GitHub token scope mismatch");
|
|
32
|
+
const actual = response.permissions;
|
|
33
|
+
if (
|
|
34
|
+
Object.entries(actual).some(([name, value]) => requested[name] !== value) ||
|
|
35
|
+
Object.keys(requested).some((name) => !(name in actual) && !(name === "metadata" && requested[name] === "read"))
|
|
36
|
+
) {
|
|
37
|
+
throw new Error("GitHub token scope mismatch");
|
|
38
|
+
}
|
|
39
|
+
if (
|
|
40
|
+
response.repository_selection !== "selected" ||
|
|
41
|
+
!Array.isArray(response.repositories) ||
|
|
42
|
+
response.repositories.length !== 1 ||
|
|
43
|
+
!record(response.repositories[0]) ||
|
|
44
|
+
typeof response.repositories[0].full_name !== "string" ||
|
|
45
|
+
response.repositories[0].full_name.toLowerCase() !== repo.toLowerCase()
|
|
46
|
+
) {
|
|
47
|
+
throw new Error("GitHub token scope mismatch");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -1,45 +1,17 @@
|
|
|
1
|
-
/** The
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* executor before every `/exec` it sends
|
|
8
|
-
* (src/execution/sandboxCredentials.ts, issue 1915).
|
|
9
|
-
*
|
|
10
|
-
* Background: attach writes a 1-hour GitHub App installation token
|
|
11
|
-
* into `<worktree>/.git/github-credentials` and points git's `store` helper
|
|
12
|
-
* at it. Nothing used to refresh it during a run, so a push more than ~60
|
|
13
|
-
* minutes after attach got 401 — and git's `store` helper ERASES a rejected
|
|
14
|
-
* credential from its file, leaving the agent staring at an empty file. The
|
|
15
|
-
* Worker now asks this function before every writable `/exec` and re-mints
|
|
16
|
-
* (cached per slug) + rewrites the file when it says so.
|
|
17
|
-
*
|
|
18
|
-
* The refresh is driven by the TOKEN'S OWN EXPIRY, not the file's age.
|
|
19
|
-
* `mintRepoScopedToken` caches per repository slug, so a token minted for an
|
|
20
|
-
* earlier thread — with only a few minutes of life left — could be written
|
|
21
|
-
* for a brand-new attach and, keyed on file age alone, read "fresh" for ~45
|
|
22
|
-
* minutes while every writable exec in between ran on a dead token (the first
|
|
23
|
-
* git write 401s, `store` blanks the file, and only the NEXT exec refreshed).
|
|
24
|
-
* Now the binding records the token's `expiresAtMs`, and this predicate
|
|
25
|
-
* refreshes once the token is within `CREDENTIAL_EXPIRY_MARGIN_MS` of expiry
|
|
26
|
-
* — so a writable exec never starts on a token that cannot outlive it. */
|
|
1
|
+
/** The pure expiry decision retained for the optional legacy sandbox
|
|
2
|
+
* credential refresher. The production executor factory does not supply
|
|
3
|
+
* that credential source: model workspaces use a revocable Git-door run
|
|
4
|
+
* bearer, and the resident never refreshes a model-visible App token.
|
|
5
|
+
* The resident imports only the expiry margin for its root-owned mirror
|
|
6
|
+
* credential cache. */
|
|
27
7
|
|
|
28
8
|
import { BASH_TIMEOUT_MAX_MS } from "./bashTimeout.js";
|
|
29
9
|
|
|
30
|
-
/**
|
|
31
|
-
* this long after the file was written when the token's real expiry is
|
|
32
|
-
* unknown — comfortably before the 60-minute token expiry. New bindings carry
|
|
33
|
-
* the expiry and take the margin path below instead. */
|
|
10
|
+
/** Legacy refresher backstop when the stored token's expiry is unknown. */
|
|
34
11
|
export const CREDENTIAL_REFRESH_AFTER_MS = 45 * 60_000;
|
|
35
12
|
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
* Sized like the sandbox's per-command credential margin: the longest
|
|
39
|
-
* single exec is `BASH_TIMEOUT_MAX_MS` (20 min), plus slack for the mint +
|
|
40
|
-
* file write and clock skew ⇒ 25 min. This is also `mintRepoScopedToken`'s
|
|
41
|
-
* cache serve threshold, so the cache and this predicate agree: a token the
|
|
42
|
-
* cache would still hand out is a token this predicate would not refresh. */
|
|
13
|
+
/** Legacy refresh and root-mirror cache margin: the longest exec is
|
|
14
|
+
* `BASH_TIMEOUT_MAX_MS` (20 min), plus five minutes for mint and clock skew. */
|
|
43
15
|
export const CREDENTIAL_EXPIRY_MARGIN_MS = BASH_TIMEOUT_MAX_MS + 5 * 60_000;
|
|
44
16
|
|
|
45
17
|
export type CredentialRefreshReason =
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Persist uncertainty before asking the platform to destroy a resident VM.
|
|
2
|
+
* A rejected or interrupted destroy leaves the fence in storage: callers may
|
|
3
|
+
* retry destruction, but may not provision or assign a pool user on the old
|
|
4
|
+
* disk. A failed clear is conservative too, even if the VM was destroyed. */
|
|
5
|
+
export async function destroyWithPersistentFence(
|
|
6
|
+
fence: { mark(): Promise<void>; clear(): Promise<void> },
|
|
7
|
+
destroy: () => Promise<void>,
|
|
8
|
+
): Promise<void> {
|
|
9
|
+
await fence.mark();
|
|
10
|
+
await destroy();
|
|
11
|
+
await fence.clear();
|
|
12
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Durable UID ownership within one VM generation. Missing or malformed
|
|
2
|
+
* records are unknown generations, never an empty pool. */
|
|
3
|
+
export interface SpentPoolUser {
|
|
4
|
+
user: string;
|
|
5
|
+
owner: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseSpentPoolUsers(value: unknown, pool: readonly string[]): ReadonlyMap<string, string> | null {
|
|
9
|
+
if (
|
|
10
|
+
!Array.isArray(value) ||
|
|
11
|
+
!value.every(
|
|
12
|
+
(row): row is SpentPoolUser =>
|
|
13
|
+
row !== null &&
|
|
14
|
+
typeof row === "object" &&
|
|
15
|
+
"user" in row &&
|
|
16
|
+
"owner" in row &&
|
|
17
|
+
typeof row.user === "string" &&
|
|
18
|
+
typeof row.owner === "string" &&
|
|
19
|
+
/^(thread|op):\S+$/.test(row.owner),
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
return null;
|
|
23
|
+
const spent = new Map(value.map(({ user, owner }) => [user, owner]));
|
|
24
|
+
if (spent.size !== value.length || [...spent.keys()].some((user) => !pool.includes(user))) return null;
|
|
25
|
+
return spent;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A new owner must get an unused UID; only the exact recorded owner may
|
|
29
|
+
* reclaim its UID. Both paths record ownership before untrusted work. */
|
|
30
|
+
export function spendPoolUser(
|
|
31
|
+
value: unknown,
|
|
32
|
+
pool: readonly string[],
|
|
33
|
+
user: string,
|
|
34
|
+
owner: string,
|
|
35
|
+
): SpentPoolUser[] | null {
|
|
36
|
+
const spent = parseSpentPoolUsers(value, pool);
|
|
37
|
+
if (!spent || !pool.includes(user) || !/^(thread|op):\S+$/.test(owner)) return null;
|
|
38
|
+
const prior = spent.get(user);
|
|
39
|
+
if (prior && prior !== owner) return null;
|
|
40
|
+
const rows = [...spent].map(([user, owner]) => ({ user, owner }));
|
|
41
|
+
return prior ? rows : [...rows, { user, owner }];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A UID-scoped command must have exactly one live owner. An old duplicate
|
|
45
|
+
* binding never gains authority merely because its UID is spent. */
|
|
46
|
+
export function mayRunAsPoolUser(
|
|
47
|
+
value: unknown,
|
|
48
|
+
pool: readonly string[],
|
|
49
|
+
user: string,
|
|
50
|
+
liveThreadKeys: readonly string[],
|
|
51
|
+
activeOpOwner: string | undefined,
|
|
52
|
+
expectedOwner?: string,
|
|
53
|
+
): boolean {
|
|
54
|
+
const recorded = parseSpentPoolUsers(value, pool)?.get(user);
|
|
55
|
+
if (!recorded || (expectedOwner !== undefined && recorded !== expectedOwner)) return false;
|
|
56
|
+
if (activeOpOwner !== undefined) return liveThreadKeys.length === 0 && recorded === activeOpOwner;
|
|
57
|
+
return liveThreadKeys.length === 1 && recorded === `thread:${liveThreadKeys[0]}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One row per UID is rebuilt from primary bindings when a DO starts. A
|
|
61
|
+
* malformed or missing row cannot authorize a shell, even if the spend ledger
|
|
62
|
+
* names the caller. Keep every live claimant so duplicates remain visible. */
|
|
63
|
+
export function parsePoolBindings(value: unknown): string[] | null {
|
|
64
|
+
if (!Array.isArray(value) || !value.every((key): key is string => typeof key === "string" && key.length > 0))
|
|
65
|
+
return null;
|
|
66
|
+
if (new Set(value).size !== value.length) return null;
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function claimPoolBinding(value: unknown, threadKey: string): string[] | null {
|
|
71
|
+
const current = parsePoolBindings(value);
|
|
72
|
+
if (!current || !threadKey || current.includes(threadKey)) return null;
|
|
73
|
+
return [...current, threadKey];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function releasePoolBinding(value: unknown, threadKey: string): string[] | null {
|
|
77
|
+
const current = parsePoolBindings(value);
|
|
78
|
+
if (!current || !threadKey || !current.includes(threadKey)) return null;
|
|
79
|
+
return current.filter((key) => key !== threadKey);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function rebuildPoolBindingIndex(
|
|
83
|
+
bindings: Iterable<{ threadKey: string; user: string; evicted?: boolean }>,
|
|
84
|
+
pool: readonly string[],
|
|
85
|
+
): Map<string, string[]> | null {
|
|
86
|
+
const byUser = new Map(pool.map((user) => [user, [] as string[]]));
|
|
87
|
+
for (const binding of bindings) {
|
|
88
|
+
if (binding?.evicted === true) continue;
|
|
89
|
+
if (!binding || typeof binding.threadKey !== "string" || !binding.threadKey) return null;
|
|
90
|
+
if (!binding.user) continue;
|
|
91
|
+
const keys = byUser.get(binding.user);
|
|
92
|
+
if (!keys || keys.includes(binding.threadKey)) return null;
|
|
93
|
+
keys.push(binding.threadKey);
|
|
94
|
+
}
|
|
95
|
+
return byUser;
|
|
96
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** An operator recreate closes new admission before its idle read and keeps a
|
|
2
|
+
* durable refusal across the container destroy. A new DO isolate may reclaim
|
|
3
|
+
* the mark only by re-running the operator's checked recreate. */
|
|
4
|
+
export class ResidentRecreateAdmission {
|
|
5
|
+
private claiming = false;
|
|
6
|
+
|
|
7
|
+
constructor(
|
|
8
|
+
private readonly store: {
|
|
9
|
+
mark(): Promise<void>;
|
|
10
|
+
clear(): Promise<void>;
|
|
11
|
+
held(): Promise<boolean>;
|
|
12
|
+
},
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
get pending(): boolean {
|
|
16
|
+
return this.claiming;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async blocked(): Promise<boolean> {
|
|
20
|
+
return this.claiming || (await this.store.held());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async run<T>(action: () => Promise<T>): Promise<{ busy: true } | { busy: false; value: T }> {
|
|
24
|
+
if (this.claiming) return { busy: true };
|
|
25
|
+
this.claiming = true; // no await before a concurrent admission sees this
|
|
26
|
+
let marked = false;
|
|
27
|
+
try {
|
|
28
|
+
await this.store.mark();
|
|
29
|
+
marked = true;
|
|
30
|
+
return { busy: false, value: await action() };
|
|
31
|
+
} finally {
|
|
32
|
+
try {
|
|
33
|
+
// A failed mark may already have persisted. Leave it fail-closed for
|
|
34
|
+
// an explicit checked retry rather than guessing that it did not.
|
|
35
|
+
if (marked) await this.store.clear();
|
|
36
|
+
} finally {
|
|
37
|
+
this.claiming = false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -29,6 +29,7 @@ export const RESIDENT_STEP_LABELS = {
|
|
|
29
29
|
git: "a git command",
|
|
30
30
|
"git-setup": "configuring git",
|
|
31
31
|
"stage-perms": "securing the credential stage",
|
|
32
|
+
"stage-write-cleanup": "removing a failed staged write",
|
|
32
33
|
install: "installing dependencies",
|
|
33
34
|
build: "building",
|
|
34
35
|
test: "running the tests",
|