toolroll 0.9.96 → 0.9.98
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/CHANGELOG.md +18 -0
- package/dist/approval-authority.d.ts +10 -1
- package/dist/approval-authority.js +18 -2
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +32 -32
- package/dist/browser-workspace.d.ts +6 -3
- package/dist/chat-actions.d.ts +40 -5
- package/dist/chat-actions.js +124 -13
- package/dist/chat-card.js +4 -1
- package/dist/chat-channel.d.ts +3 -0
- package/dist/chat-channel.js +24 -7
- package/dist/chat-decide.js +4 -2
- package/dist/chat-delivery.d.ts +5 -1
- package/dist/chat-delivery.js +74 -25
- package/dist/chat-follow.d.ts +6 -3
- package/dist/chat-follow.js +23 -7
- package/dist/chat-live-schema.d.ts +17 -0
- package/dist/chat-live-schema.js +32 -0
- package/dist/chat-live.d.ts +161 -0
- package/dist/chat-live.js +491 -0
- package/dist/chat-task-actions.js +1 -1
- package/dist/chat-terms.d.ts +8 -0
- package/dist/chat-terms.js +14 -0
- package/dist/chat-voice.js +1 -1
- package/dist/contracts/chat-actions.d.ts +6 -0
- package/dist/contracts/chat-actions.js +2 -0
- package/dist/contracts/cli.d.ts +6 -0
- package/dist/contracts/cli.js +3 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/lead-tools.d.ts +17 -0
- package/dist/contracts/lead-tools.js +4 -1
- package/dist/contracts/store-json.d.ts +1 -0
- package/dist/discord-api.d.ts +3 -1
- package/dist/discord-api.js +9 -3
- package/dist/discord-chat.d.ts +13 -3
- package/dist/discord-chat.js +77 -21
- package/dist/discord.js +8 -1
- package/dist/fast-start.d.ts +83 -6
- package/dist/fast-start.js +112 -15
- package/dist/home-groups.d.ts +39 -0
- package/dist/home-groups.js +33 -0
- package/dist/lead-answer-eval.d.ts +93 -0
- package/dist/lead-answer-eval.js +259 -0
- package/dist/lead-capabilities.js +1 -1
- package/dist/lead-cli-live.d.ts +56 -0
- package/dist/lead-cli-live.js +200 -0
- package/dist/lead-cli.d.ts +3 -0
- package/dist/lead-cli.js +34 -4
- package/dist/lead-context.js +2 -2
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +10 -7
- package/dist/lead-doors.d.ts +32 -0
- package/dist/lead-doors.js +80 -14
- package/dist/lead-latency-schema.d.ts +24 -0
- package/dist/lead-latency-schema.js +36 -0
- package/dist/lead-progress.d.ts +10 -1
- package/dist/lead-progress.js +1 -1
- package/dist/lead-queue-runtime.d.ts +1 -1
- package/dist/lead-queue-runtime.js +1 -1
- package/dist/lead-route.d.ts +56 -0
- package/dist/lead-route.js +104 -0
- package/dist/lead-status.d.ts +3 -0
- package/dist/lead-status.js +8 -3
- package/dist/lead-tools.d.ts +20 -0
- package/dist/lead-tools.js +66 -3
- package/dist/lead-voice.js +1 -1
- package/dist/lead.d.ts +4 -2
- package/dist/lead.js +97 -28
- package/dist/live-reply.d.ts +83 -0
- package/dist/live-reply.js +302 -0
- package/dist/local-owner.js +2 -0
- package/dist/one-step-chat-schema.d.ts +22 -0
- package/dist/one-step-chat-schema.js +37 -0
- package/dist/operate.d.ts +27 -1
- package/dist/operate.js +245 -33
- package/dist/project-delete.js +9 -0
- package/dist/project-knowledge.d.ts +10 -10
- package/dist/publish.d.ts +2 -0
- package/dist/publish.js +1 -1
- package/dist/pull-request-flow.d.ts +4 -0
- package/dist/pull-request-flow.js +16 -1
- package/dist/reply-shape.d.ts +9 -0
- package/dist/reply-shape.js +13 -0
- package/dist/reply-voice.d.ts +23 -0
- package/dist/reply-voice.js +37 -0
- package/dist/scope.d.ts +6 -1
- package/dist/scope.js +3 -2
- package/dist/serve.js +11 -4
- package/dist/server/chat.d.ts +2 -1
- package/dist/server/chat.js +22 -10
- package/dist/server/chrome.js +2 -2
- package/dist/server/home.d.ts +4 -3
- package/dist/server/home.js +33 -28
- package/dist/server/live.js +18 -21
- package/dist/server/render-chat.d.ts +5 -0
- package/dist/server/render-chat.js +15 -8
- package/dist/server/render-pages.js +2 -2
- package/dist/server/render-settings.d.ts +8 -0
- package/dist/server/render-settings.js +13 -2
- package/dist/server/route-table.js +2 -0
- package/dist/server/settings.js +14 -1
- package/dist/setup-guide.d.ts +2 -2
- package/dist/slack-api.js +11 -0
- package/dist/slack-chat.d.ts +11 -1
- package/dist/slack-chat.js +50 -5
- package/dist/slack-settings.js +8 -1
- package/dist/slack.js +10 -3
- package/dist/store.d.ts +33 -2
- package/dist/store.js +92 -16
- package/dist/subscription-chat.d.ts +2 -0
- package/dist/subscription-chat.js +3 -1
- package/dist/teams-chat.d.ts +9 -2
- package/dist/teams-chat.js +50 -2
- package/dist/teams.js +5 -2
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +132 -14
- package/dist/telegram-live.d.ts +116 -0
- package/dist/telegram-live.js +224 -0
- package/dist/telegram-status.js +9 -4
- package/dist/telegram.d.ts +12 -0
- package/dist/telegram.js +142 -10
- package/dist/toolroll-update.js +6 -1
- package/dist/tui-contract.d.ts +2 -0
- package/dist/tui-source.js +3 -0
- package/dist/tui.js +41 -41
- package/dist/typed-start.d.ts +51 -0
- package/dist/typed-start.js +98 -0
- package/dist/wake-hub.d.ts +72 -0
- package/dist/wake-hub.js +151 -0
- package/package.json +1 -1
package/dist/operate.js
CHANGED
|
@@ -9,6 +9,7 @@ import { adapterPolicy } from "./server/route-policy.js";
|
|
|
9
9
|
import { UNSENT_REPLY_MS } from "./telegram-settings.js";
|
|
10
10
|
import { checkPublicCommand } from "./public-check.js";
|
|
11
11
|
import { leadNameOf } from "./lead-identity.js";
|
|
12
|
+
import { modelName, quickLeadModel } from "./lead-route.js";
|
|
12
13
|
import { maybeTriggerRepair } from "./dispose.js";
|
|
13
14
|
import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
|
|
14
15
|
import { fileAddTestsTask, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
|
|
@@ -122,7 +123,7 @@ import { homedir, hostname } from "node:os";
|
|
|
122
123
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
123
124
|
import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
|
|
124
125
|
import { dur } from "./design/kit.js";
|
|
125
|
-
import { checkoutIdle, setCheckoutIdle, openStore, openStoreNoMigrate, initLocalMode, enrollOwnerPassword, installationCreatedBy, laptopPortOf, recordLaptopPort, localModeOf, localOwnerBinding, archiveTaskFamily, restoreTaskFamily, shipChecksForTask, elapsedSeconds, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
|
|
126
|
+
import { checkoutIdle, setCheckoutIdle, leadQuickAnswersOn, setLeadQuickAnswers, openStore, openStoreNoMigrate, initLocalMode, enrollOwnerPassword, installationCreatedBy, laptopPortOf, recordLaptopPort, localModeOf, localOwnerBinding, archiveTaskFamily, restoreTaskFamily, shipChecksForTask, elapsedSeconds, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
|
|
126
127
|
import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
|
|
127
128
|
import { claimInstance, clearInstance, openInstanceSocket } from "./console-instance.js";
|
|
128
129
|
import { backgroundOff, backgroundOn, backgroundShow, laptopStatus, laptopStop, restartOwnBuilder } from "./laptop-background.js";
|
|
@@ -208,6 +209,7 @@ import { scanRepo } from "./capscan.js";
|
|
|
208
209
|
import { computeGaps, describeCapability } from "./gaps.js";
|
|
209
210
|
import { ask as promptAsk, askHidden as promptHidden, confirm as promptConfirm, interactive as promptInteractive, underAgent } from "./prompt.js";
|
|
210
211
|
import { runLeadCli, answerContextLines } from "./lead-cli.js";
|
|
212
|
+
import { liveTerminalWanted, processTerminal } from "./lead-cli-live.js";
|
|
211
213
|
import { confirmCoordinatorProposal, dismissCoordinatorProposal } from "./lead-doors.js";
|
|
212
214
|
import { verifyApproverByPassword as approverByPassword } from "./principal.js";
|
|
213
215
|
import { activeRemote, REMOTE_MESSAGES, REMOTE_REFUSED_FLAGS, REMOTE_SCOPES, RemoteRefusal, remoteAllows, remoteLensOf, remoteProjectOf, remoteRowOf, remoteSecret, reproveRemote, withRemote } from "./operate-remote.js";
|
|
@@ -279,6 +281,7 @@ import { noticeGitIncidents } from "./git-incident.js";
|
|
|
279
281
|
import { containmentNotice, containmentStatus, currentContainment, describeContainment, resolveContainment } from "./containment.js";
|
|
280
282
|
import { readPulls } from "./pulls.js";
|
|
281
283
|
import { startMaintenance } from "./maintenance.js";
|
|
284
|
+
import { WakePoker, wakeHubFor } from "./wake-hub.js";
|
|
282
285
|
import { livePin, modelWatchPass } from "./model-catalog.js";
|
|
283
286
|
import { runModelsCommand } from "./models-cli.js";
|
|
284
287
|
import { ALL_PROJECTS, chatApprovalSettings, chatApprovalWords, setChatApproval } from "./chat-approval.js";
|
|
@@ -598,6 +601,9 @@ Agents — which provider and model each phase runs on
|
|
|
598
601
|
[--model <m>] [--weekly-usd <n>] [--daily-turns <n>] --as <you> --token <t>
|
|
599
602
|
membership providers reuse a logged-in local harness with no dollar
|
|
600
603
|
maximum; direct API providers require a key and weekly dollar ceiling
|
|
604
|
+
toolroll config set|clear|show quick-answers --as <you> --token <t>
|
|
605
|
+
quick answers on (set) or off (clear): plain status questions go to a
|
|
606
|
+
faster model on the Claude membership; changes stay on the main model
|
|
601
607
|
toolroll chat --as <you> [--repo <path>…] [--say "…"] [--end]
|
|
602
608
|
[--ceiling-usd <n>] [--json] (password at the prompt;
|
|
603
609
|
--token <t> only for scripts — it lands in shell history)
|
|
@@ -5101,6 +5107,9 @@ async function configCommand(positional, flags, context) {
|
|
|
5101
5107
|
return fail(write, json, "config", "usage", `unknown \`config ${action}\` — try ${CONFIG_ACTIONS.join(", ")}`, EXIT.usage);
|
|
5102
5108
|
const repoGiven = text(flags, "repo");
|
|
5103
5109
|
const scope = repoGiven === undefined ? INSTALLATION_SCOPE : canonicalProject(repoGiven) ?? resolve(repoGiven);
|
|
5110
|
+
// Settings → Lead's "Quick answers use a faster model", the same switch (responsive-chat.md §8).
|
|
5111
|
+
if (phase === "quick-answers")
|
|
5112
|
+
return quickAnswersConfig(action ?? "show", repoGiven !== undefined, flags, context);
|
|
5104
5113
|
if (action === undefined || action === "show") {
|
|
5105
5114
|
const installation = store.listPhaseConfig(INSTALLATION_SCOPE);
|
|
5106
5115
|
const project = scope === INSTALLATION_SCOPE ? [] : store.listPhaseConfig(scope);
|
|
@@ -6419,8 +6428,22 @@ async function runWatchLoop(args) {
|
|
|
6419
6428
|
// an authority this process no longer holds.
|
|
6420
6429
|
let leaseLost = false;
|
|
6421
6430
|
const followController = new AbortController();
|
|
6431
|
+
// The process's one wake poller (wake-hub.ts) replaces every per-loop and per-lane 50 ms read of the wake counter.
|
|
6432
|
+
// It only says "look": every claim still re-proves its fences.
|
|
6433
|
+
const hub = wakeHubFor(store);
|
|
6434
|
+
const wakes = new WakePoker();
|
|
6422
6435
|
args.onFollowController?.(followController);
|
|
6423
|
-
|
|
6436
|
+
// The loop or a build lane failed (the store could not be read): everything stops, cleans up, and then the first
|
|
6437
|
+
// failure is thrown. Without this a failed lane skipped the cleanup, and a failed loop left its lanes running.
|
|
6438
|
+
let failed = false;
|
|
6439
|
+
let failure;
|
|
6440
|
+
const fail = (error) => {
|
|
6441
|
+
if (!failed)
|
|
6442
|
+
failure = error;
|
|
6443
|
+
failed = true;
|
|
6444
|
+
wakes.poke();
|
|
6445
|
+
};
|
|
6446
|
+
const stopping = () => args.isStopping() || leaseLost || failed;
|
|
6424
6447
|
const heartbeat = setInterval(() => {
|
|
6425
6448
|
let renewed = false;
|
|
6426
6449
|
let problem = "the lease or credential was taken";
|
|
@@ -6433,6 +6456,8 @@ async function runWatchLoop(args) {
|
|
|
6433
6456
|
if (!renewed && !leaseLost) {
|
|
6434
6457
|
leaseLost = true;
|
|
6435
6458
|
followController.abort();
|
|
6459
|
+
// A lost lease ends every doze of this loop and its lanes now, not at the next beat (live-wakeups.md).
|
|
6460
|
+
wakes.poke();
|
|
6436
6461
|
progress(`watch: ${runner} on ${repo}: ${problem} — stopping without admitting more work`);
|
|
6437
6462
|
}
|
|
6438
6463
|
}, WATCH_HEARTBEAT_MS);
|
|
@@ -6487,12 +6512,9 @@ async function runWatchLoop(args) {
|
|
|
6487
6512
|
const deadline = runFor === null ? null : startedAt + runFor;
|
|
6488
6513
|
let lastTick = 0;
|
|
6489
6514
|
let lastBridge = 0;
|
|
6490
|
-
let lastPush = 0;
|
|
6491
|
-
let lastWebhook = 0;
|
|
6492
6515
|
let ticks = 0;
|
|
6493
6516
|
let built = 0;
|
|
6494
6517
|
let brokeCount = 0;
|
|
6495
|
-
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
6496
6518
|
// Reconciliation has its own cadence and sink. Sharing tick's sink would
|
|
6497
6519
|
// interleave envelopes while a build awaits its provider or project check.
|
|
6498
6520
|
const sweepOwner = {};
|
|
@@ -6517,8 +6539,12 @@ async function runWatchLoop(args) {
|
|
|
6517
6539
|
throw new Error(maintenanceLines.join(" "));
|
|
6518
6540
|
if (reconciliationFailure !== null)
|
|
6519
6541
|
progress("watch: reconciliation recovered — new work may resume");
|
|
6542
|
+
const recovered = !reconciliationReady;
|
|
6520
6543
|
reconciliationFailure = null;
|
|
6521
6544
|
reconciliationReady = true;
|
|
6545
|
+
// Admission resumes now: a loop dozing while paused looks again at once.
|
|
6546
|
+
if (recovered)
|
|
6547
|
+
wakes.poke();
|
|
6522
6548
|
if (!stopping() && store.openedPublications().length > 0) {
|
|
6523
6549
|
try {
|
|
6524
6550
|
const checks = await observeChecks(store, {
|
|
@@ -6646,6 +6672,9 @@ async function runWatchLoop(args) {
|
|
|
6646
6672
|
run: async () => { if (await modelWatchPass(store, context.clock()))
|
|
6647
6673
|
progress("watch: checked models and CLI versions"); },
|
|
6648
6674
|
});
|
|
6675
|
+
// The process's delivery and fast CI beats (live-wakeups.md): one set however many project loops run.
|
|
6676
|
+
// The fast CI pass runs only while this loop would admit work: not stopping, reconciled, and no update drain.
|
|
6677
|
+
const leaveServiceBeats = joinServiceBeats({ context, progress, ciReady: () => !admissionStopped() });
|
|
6649
6678
|
// Parallel builds (project-concurrency.ts): this loop is the project's
|
|
6650
6679
|
// first build lane and keeps every housekeeping duty; extra lanes, up to
|
|
6651
6680
|
// the project's own number (never past the worker's capacity), only
|
|
@@ -6721,16 +6750,16 @@ async function runWatchLoop(args) {
|
|
|
6721
6750
|
const seqIdle = store.wakeSeq();
|
|
6722
6751
|
const dozeUntil = Math.min(Date.now() + tickEveryMs, deadline ?? Number.MAX_SAFE_INTEGER);
|
|
6723
6752
|
const theirTurnEnded = () => yieldingTo !== null && yieldingSince !== null && passes.lastPass(yieldingTo) > yieldingSince;
|
|
6724
|
-
|
|
6725
|
-
|
|
6726
|
-
}
|
|
6753
|
+
// Doze on the shared poller: a wake, the cadence, a stop, a lost lease or the fair-share turn ending.
|
|
6754
|
+
await hub.changed({ baseline: seqIdle, deadline: dozeUntil, until: () => stopping() || theirTurnEnded(), poker: wakes });
|
|
6727
6755
|
}
|
|
6728
6756
|
};
|
|
6729
6757
|
const ensureLanes = () => {
|
|
6730
6758
|
const facts = slotFacts(null);
|
|
6731
6759
|
const wanted = effectiveConcurrency(facts.limitOf(repo), facts.capacity);
|
|
6760
|
+
// A lane never rejects: its failure is kept, every doze is woken, and the loop's own cleanup runs before it is thrown.
|
|
6732
6761
|
while (lanes.length + 1 < wanted && !stopping())
|
|
6733
|
-
lanes.push(buildLane(lanes.length + 1));
|
|
6762
|
+
lanes.push(buildLane(lanes.length + 1).catch(fail));
|
|
6734
6763
|
};
|
|
6735
6764
|
let firstLaneYieldingSince = null;
|
|
6736
6765
|
let firstLaneYieldingTo = null;
|
|
@@ -6739,7 +6768,9 @@ async function runWatchLoop(args) {
|
|
|
6739
6768
|
await maintenance.runNow();
|
|
6740
6769
|
while (!stopping() && (deadline === null || Date.now() < deadline)) {
|
|
6741
6770
|
if (paused()) {
|
|
6742
|
-
|
|
6771
|
+
// Paused: look again when the wake moves (pause and resume bump it), when reconciliation recovers (a poke),
|
|
6772
|
+
// or after a second at most, whichever comes first.
|
|
6773
|
+
await hub.changed({ baseline: store.wakeSeq(), deadline: Date.now() + 1_000, until: stopping, poker: wakes });
|
|
6743
6774
|
continue;
|
|
6744
6775
|
}
|
|
6745
6776
|
ensureLanes();
|
|
@@ -6809,25 +6840,8 @@ async function runWatchLoop(args) {
|
|
|
6809
6840
|
(published.failed > 0 ? `, gave up on ${published.failed}` : ""));
|
|
6810
6841
|
}
|
|
6811
6842
|
}
|
|
6812
|
-
//
|
|
6813
|
-
//
|
|
6814
|
-
if (now - lastPush >= 45_000) {
|
|
6815
|
-
lastPush = now;
|
|
6816
|
-
try {
|
|
6817
|
-
await pushPass(context.store, { configDir: dirname(context.databaseFile), clock: () => new Date() });
|
|
6818
|
-
}
|
|
6819
|
-
catch {
|
|
6820
|
-
// Push is additive; a broken pass never stops the watch.
|
|
6821
|
-
}
|
|
6822
|
-
}
|
|
6823
|
-
if (now - lastWebhook >= bridgeEveryMs) {
|
|
6824
|
-
lastWebhook = now;
|
|
6825
|
-
const dir = dirname(context.databaseFile);
|
|
6826
|
-
const primary = effectivePrimary(process.env, dir, loadBotToken(process.env, context.telegramTokenFile) !== null);
|
|
6827
|
-
const targets = activeWebhookTargets(process.env, dir, primary.channel);
|
|
6828
|
-
if (targets.length > 0)
|
|
6829
|
-
await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock: context.clock });
|
|
6830
|
-
}
|
|
6843
|
+
// Web push and outgoing webhooks ride the service's own delivery beat (joinServiceBeats, live-wakeups.md):
|
|
6844
|
+
// every 5 s, and at once when the wake moves with new rows, never behind a build on this path.
|
|
6831
6845
|
// The embedded follower owns the wire while it lives; the timer-driven
|
|
6832
6846
|
// pass is the fallback shape for a watch started before a token existed.
|
|
6833
6847
|
if (follower === null && now - lastBridge >= bridgeEveryMs) {
|
|
@@ -6856,7 +6870,7 @@ async function runWatchLoop(args) {
|
|
|
6856
6870
|
// due, a signal lands, or the trial window (--for) ends. Reading one
|
|
6857
6871
|
// integer from SQLite twice a second is the entire idle cost — no
|
|
6858
6872
|
// token has anywhere to be spent from here.
|
|
6859
|
-
const idleUntil = Math.min(lastTick + tickEveryMs, lastBridge + bridgeEveryMs,
|
|
6873
|
+
const idleUntil = Math.min(lastTick + tickEveryMs, lastBridge + bridgeEveryMs, deadline ?? Number.MAX_SAFE_INTEGER) - Date.now();
|
|
6860
6874
|
// Doze for the WHOLE idle window, waking early only for a signal or a
|
|
6861
6875
|
// sequence move — not for one 500ms step and then another pass
|
|
6862
6876
|
// (2026-09-05: that read as two ticks a second, all night, with
|
|
@@ -6864,11 +6878,13 @@ async function runWatchLoop(args) {
|
|
|
6864
6878
|
const seqIdle = store.wakeSeq();
|
|
6865
6879
|
const dozeUntil = Date.now() + Math.max(idleUntil, 0);
|
|
6866
6880
|
const theirTurnEnded = () => firstLaneYieldingTo !== null && firstLaneYieldingSince !== null && passes.lastPass(firstLaneYieldingTo) > firstLaneYieldingSince;
|
|
6867
|
-
|
|
6868
|
-
await sleep(50);
|
|
6869
|
-
}
|
|
6881
|
+
await hub.changed({ baseline: seqIdle, deadline: dozeUntil, until: () => stopping() || theirTurnEnded(), poker: wakes });
|
|
6870
6882
|
}
|
|
6871
6883
|
}
|
|
6884
|
+
catch (error) {
|
|
6885
|
+
// Kept, not thrown yet: the lanes must see the stop, and the cleanup below must run first.
|
|
6886
|
+
fail(error);
|
|
6887
|
+
}
|
|
6872
6888
|
finally {
|
|
6873
6889
|
// Every build lane finishes or hands its attempt back before the
|
|
6874
6890
|
// loop's lease and episode end.
|
|
@@ -6879,6 +6895,7 @@ async function runWatchLoop(args) {
|
|
|
6879
6895
|
await flowMoves.stop();
|
|
6880
6896
|
await flowSteps.stop();
|
|
6881
6897
|
await modelWatch.stop();
|
|
6898
|
+
await leaveServiceBeats();
|
|
6882
6899
|
clearInterval(heartbeat);
|
|
6883
6900
|
followController.abort();
|
|
6884
6901
|
if (follower !== null)
|
|
@@ -6889,6 +6906,8 @@ async function runWatchLoop(args) {
|
|
|
6889
6906
|
store.endWatchEpisode(incarnation, { ticks, built, broke: brokeCount }, new Date());
|
|
6890
6907
|
store.releaseWatchLease(runner, repo, incarnation, new Date());
|
|
6891
6908
|
}
|
|
6909
|
+
if (failed)
|
|
6910
|
+
throw failure;
|
|
6892
6911
|
if (leaseLost) {
|
|
6893
6912
|
return { ok: false, reason: "lease-lost", detail: `the watch lease for ${runner} on ${repo} stopped renewing — another process may have taken this worker over`, ticks, built, broke: brokeCount };
|
|
6894
6913
|
}
|
|
@@ -6958,6 +6977,7 @@ async function watchCommand(flags, context) {
|
|
|
6958
6977
|
}
|
|
6959
6978
|
stopping = true;
|
|
6960
6979
|
followAbort?.abort();
|
|
6980
|
+
wakeHubFor(context.store).poke();
|
|
6961
6981
|
graceTimer = setTimeout(hardStop, stopGraceMs);
|
|
6962
6982
|
};
|
|
6963
6983
|
process.on("SIGINT", stop);
|
|
@@ -10671,6 +10691,8 @@ async function chatCommand(flags, context) {
|
|
|
10671
10691
|
...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
|
|
10672
10692
|
// The console's own address (and its public one): links there read as "the task", "the result", "Settings → Lead".
|
|
10673
10693
|
appOrigin: [loadConsoleUrl(process.env, dirname(context.databaseFile)), phoneOrigin(process.env, dirname(context.databaseFile))],
|
|
10694
|
+
// A live status line and the answer as it is written, on a real terminal only (tests drive runLeadCli directly).
|
|
10695
|
+
...(context.leadSeams === undefined && liveTerminalWanted(json, process.env, process.stdout.isTTY === true, () => underAgent(process.env)) ? { liveTerminal: processTerminal() } : {}),
|
|
10674
10696
|
});
|
|
10675
10697
|
return result.code;
|
|
10676
10698
|
}
|
|
@@ -12618,6 +12640,8 @@ async function runUpWorkerRole(context, config, port, build) {
|
|
|
12618
12640
|
resolveStopped();
|
|
12619
12641
|
for (const controller of followControllers)
|
|
12620
12642
|
controller.abort();
|
|
12643
|
+
// Every project loop's doze ends now (wake-hub.ts), not at its next beat.
|
|
12644
|
+
wakeHubFor(store).poke();
|
|
12621
12645
|
const building = store.liveClaimCount(runnerName, clock());
|
|
12622
12646
|
if (building > 0) {
|
|
12623
12647
|
progress(`Stopping: ${building} build(s) running. Waiting up to ${Math.round(config.stopGraceMs / 1000)}s for them to finish; any still running then is saved and resumes when the builder starts again.`);
|
|
@@ -14087,3 +14111,191 @@ async function projectFallbackCommand(positional, flags, context) {
|
|
|
14087
14111
|
return fail(context.write, context.json, command, "refused", done.problem, EXIT.refused);
|
|
14088
14112
|
return succeed(context.write, context.json, command, shown(), () => [...words(), "Tasks scoped from now on use this; tasks already approved keep the chain they were approved with."]);
|
|
14089
14113
|
}
|
|
14114
|
+
/** `toolroll config set|clear|show quick-answers`: Settings → Lead's quick-answer switch (responsive-chat.md §8). On
|
|
14115
|
+
* or off is installation-wide, so changing it takes an instance operator's password, as the web page's route does. */
|
|
14116
|
+
async function quickAnswersConfig(action, repoGiven, flags, context) {
|
|
14117
|
+
const { store, write, json, clock } = context;
|
|
14118
|
+
const command = `config ${action}`;
|
|
14119
|
+
if (repoGiven)
|
|
14120
|
+
return fail(write, json, command, "usage", "quick answers are installation-wide: no --repo", EXIT.usage);
|
|
14121
|
+
const said = () => {
|
|
14122
|
+
const on = leadQuickAnswersOn(store);
|
|
14123
|
+
const model = quickLeadModel(store);
|
|
14124
|
+
return { payload: { quickAnswers: { on, model } }, lines: () => [on
|
|
14125
|
+
? `Quick answers are on: plain status questions are answered by ${modelName(model)}; anything that asks for a change stays on the lead's main model.`
|
|
14126
|
+
: "Quick answers are off: the lead's main model answers everything."] };
|
|
14127
|
+
};
|
|
14128
|
+
if (action === "show") {
|
|
14129
|
+
const shown = said();
|
|
14130
|
+
return succeed(write, json, command, shown.payload, shown.lines);
|
|
14131
|
+
}
|
|
14132
|
+
const acting = await askCredentials(flags, context);
|
|
14133
|
+
if (acting === null)
|
|
14134
|
+
return fail(write, json, command, "usage", "changing quick answers takes `--as <you> --token <t>`", EXIT.usage);
|
|
14135
|
+
const verified = authenticateApprover(store, acting.name, acting.token);
|
|
14136
|
+
if (!verified.ok || !store.isInstanceOperator(acting.name))
|
|
14137
|
+
return fail(write, json, command, "unauthenticated", "an instance operator changes quick answers, with their password", EXIT.refused);
|
|
14138
|
+
setLeadQuickAnswers(store, action === "set", acting.name, clock());
|
|
14139
|
+
const shown = said();
|
|
14140
|
+
return succeed(write, json, command, shown.payload, shown.lines);
|
|
14141
|
+
}
|
|
14142
|
+
/** The delivery beat: web push and outgoing webhooks, off the build path. */
|
|
14143
|
+
export const DELIVERY_EVERY_MS = 5_000;
|
|
14144
|
+
/** The fast CI beat: only while an opened pull request waits on its checks or its merge. */
|
|
14145
|
+
export const FAST_CI_EVERY_MS = 30_000;
|
|
14146
|
+
/** The most pull requests one fast CI pass reads. */
|
|
14147
|
+
export const FAST_CI_LIMIT = 10;
|
|
14148
|
+
/** A pull request whose checks read as "none" stays on the fast pass this long after it was last updated: CI that
|
|
14149
|
+
* has not registered yet, not a project without CI, which the 5 minute pass keeps reading. */
|
|
14150
|
+
const FAST_CI_NONE_WINDOW_MS = 30 * 60_000;
|
|
14151
|
+
const serviceBeats = new WeakMap();
|
|
14152
|
+
/** The newest notification id: the delivery beat's high-water mark. */
|
|
14153
|
+
function newestNotificationId(store) {
|
|
14154
|
+
return Number(store.handle.prepare("SELECT COALESCE(MAX(id), 0) AS id FROM notification").get()?.["id"] ?? 0);
|
|
14155
|
+
}
|
|
14156
|
+
/**
|
|
14157
|
+
* The opened pull requests the fast CI pass reads, oldest read or tried (`attempted`) first, at most `limit`: checks not settled (never read,
|
|
14158
|
+
* running, or "none" shortly after an update), or green with its merge still waiting (a merge intent in flight, or a
|
|
14159
|
+
* followed pull request not yet marked ready on its accepted commit). Failing and settled ones stay on the 5 minute pass.
|
|
14160
|
+
*/
|
|
14161
|
+
export function fastCiWaiting(store, now, limit = FAST_CI_LIMIT, attempted = new Map()) {
|
|
14162
|
+
const waiting = store.openedPublications().filter(publication => {
|
|
14163
|
+
if (publication.prNumber === null)
|
|
14164
|
+
return false;
|
|
14165
|
+
const state = publication.lastCheckState;
|
|
14166
|
+
if (state === null || state === "running")
|
|
14167
|
+
return true;
|
|
14168
|
+
if (state === "none")
|
|
14169
|
+
return now.getTime() - Date.parse(publication.lastCheckAt ?? publication.updatedAt) < FAST_CI_NONE_WINDOW_MS
|
|
14170
|
+
&& now.getTime() - Date.parse(publication.updatedAt) < FAST_CI_NONE_WINDOW_MS;
|
|
14171
|
+
if (state !== "passing")
|
|
14172
|
+
return false;
|
|
14173
|
+
const intent = store.mergeIntentFor(publication.id);
|
|
14174
|
+
if (intent !== null && ["pending", "claimed", "waiting-human", "firing"].includes(intent.state))
|
|
14175
|
+
return true;
|
|
14176
|
+
const follow = store.handle.prepare("SELECT flow_card, ready_head, merge_commit FROM pull_request_follow WHERE publication = ?").get(publication.id);
|
|
14177
|
+
return follow !== undefined && follow["flow_card"] == null && follow["merge_commit"] == null && follow["ready_head"] !== publication.headSha;
|
|
14178
|
+
});
|
|
14179
|
+
// Oldest attempt first: a pull request GitHub cannot read still takes its turn, then goes to the back of the line.
|
|
14180
|
+
const last = (one) => {
|
|
14181
|
+
const read = one.lastCheckAt ?? "", tried = attempted.get(one.id) ?? "";
|
|
14182
|
+
return tried > read ? tried : read;
|
|
14183
|
+
};
|
|
14184
|
+
return waiting
|
|
14185
|
+
.sort((a, b) => last(a).localeCompare(last(b)) || a.id - b.id)
|
|
14186
|
+
.slice(0, limit);
|
|
14187
|
+
}
|
|
14188
|
+
/**
|
|
14189
|
+
* Join this process's service beats: one delivery beat (web push and outgoing webhooks every 5 s, and at once when
|
|
14190
|
+
* the wake moves with new notifications) and one fast CI beat (every 30 s, the existing `observeChecks` reader, only
|
|
14191
|
+
* for pull requests that wait). One set per store, however many project loops join; the last to leave stops them.
|
|
14192
|
+
* Opening a pull request stays on the tick path, and the 5 minute pass alone may set reconciliation ready.
|
|
14193
|
+
*/
|
|
14194
|
+
export function joinServiceBeats(member) {
|
|
14195
|
+
const store = member.context.store;
|
|
14196
|
+
let beats = serviceBeats.get(store);
|
|
14197
|
+
if (beats === undefined)
|
|
14198
|
+
beats = startServiceBeats(store);
|
|
14199
|
+
beats.members.add(member);
|
|
14200
|
+
const joined = beats;
|
|
14201
|
+
return async () => {
|
|
14202
|
+
if (!joined.members.delete(member) || joined.members.size > 0)
|
|
14203
|
+
return;
|
|
14204
|
+
joined.stopped = true;
|
|
14205
|
+
if (serviceBeats.get(store) === joined)
|
|
14206
|
+
serviceBeats.delete(store);
|
|
14207
|
+
joined.poker.poke();
|
|
14208
|
+
await joined.delivery.stop();
|
|
14209
|
+
await joined.fastCi.stop();
|
|
14210
|
+
await joined.wakeLoop;
|
|
14211
|
+
};
|
|
14212
|
+
}
|
|
14213
|
+
function startServiceBeats(store) {
|
|
14214
|
+
const anyMember = () => beats.members.values().next().value;
|
|
14215
|
+
let deliveredThrough = newestNotificationId(store);
|
|
14216
|
+
let deliveryProblem = null;
|
|
14217
|
+
// When the fast CI pass last tried each pull request, read or not.
|
|
14218
|
+
const attempted = new Map();
|
|
14219
|
+
const delivery = startMaintenance({
|
|
14220
|
+
intervalMs: DELIVERY_EVERY_MS,
|
|
14221
|
+
shouldStop: () => beats.stopped,
|
|
14222
|
+
onError: error => {
|
|
14223
|
+
const detail = describe(error);
|
|
14224
|
+
if (detail !== deliveryProblem)
|
|
14225
|
+
anyMember()?.progress(`watch: outgoing webhooks failed — ${detail}; they try again in a few seconds`);
|
|
14226
|
+
deliveryProblem = detail;
|
|
14227
|
+
},
|
|
14228
|
+
run: async () => {
|
|
14229
|
+
const member = anyMember();
|
|
14230
|
+
if (member === undefined)
|
|
14231
|
+
return;
|
|
14232
|
+
const { context } = member;
|
|
14233
|
+
const dir = dirname(context.databaseFile);
|
|
14234
|
+
deliveredThrough = newestNotificationId(store);
|
|
14235
|
+
// Push is additive; a broken pass never stops the watch. The pair ledger's claims fence any other sender.
|
|
14236
|
+
try {
|
|
14237
|
+
await pushPass(store, { configDir: dir, clock: () => new Date() });
|
|
14238
|
+
}
|
|
14239
|
+
catch { /* tried again next beat */ }
|
|
14240
|
+
const primary = effectivePrimary(process.env, dir, loadBotToken(process.env, context.telegramTokenFile) !== null);
|
|
14241
|
+
const targets = activeWebhookTargets(process.env, dir, primary.channel);
|
|
14242
|
+
if (targets.length > 0)
|
|
14243
|
+
await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock: context.clock });
|
|
14244
|
+
deliveryProblem = null;
|
|
14245
|
+
},
|
|
14246
|
+
});
|
|
14247
|
+
const fastCi = startMaintenance({
|
|
14248
|
+
intervalMs: FAST_CI_EVERY_MS,
|
|
14249
|
+
shouldStop: () => beats.stopped,
|
|
14250
|
+
// A failed fast pass is skipped: it records nothing and says nothing. The 5 minute pass still reads every PR.
|
|
14251
|
+
onError: () => { },
|
|
14252
|
+
run: async () => {
|
|
14253
|
+
// Every project loop must still stand, read again before each revision, ready mark and merge: a stop, a lost
|
|
14254
|
+
// lease, a failed reconciliation or an update drain that arrives while GitHub answers ends the pass there.
|
|
14255
|
+
const standing = () => !beats.stopped && beats.members.size > 0 && [...beats.members].every(one => one.ciReady());
|
|
14256
|
+
if (!standing())
|
|
14257
|
+
return;
|
|
14258
|
+
const { context, progress } = beats.members.values().next().value;
|
|
14259
|
+
const now = context.clock();
|
|
14260
|
+
const waiting = fastCiWaiting(store, now, FAST_CI_LIMIT, attempted);
|
|
14261
|
+
if (waiting.length === 0)
|
|
14262
|
+
return;
|
|
14263
|
+
// A pull request GitHub would not read keeps its place in line only until the others had their turn.
|
|
14264
|
+
if (attempted.size > 500)
|
|
14265
|
+
attempted.clear();
|
|
14266
|
+
for (const one of waiting)
|
|
14267
|
+
attempted.set(one.id, now.toISOString());
|
|
14268
|
+
const exec = context.publishExec === undefined ? {} : { exec: context.publishExec };
|
|
14269
|
+
const checks = await observeChecks(store, { ...exec, publications: waiting });
|
|
14270
|
+
if (!standing())
|
|
14271
|
+
return;
|
|
14272
|
+
if (checks.failing > 0)
|
|
14273
|
+
progress(`watch: CI is red on ${checks.failing} published PR(s) — the outbox has it`);
|
|
14274
|
+
const followed = await followPullRequests(store, checks.seen, { evidenceRoot: context.evidenceRoot, ...exec, proceed: standing });
|
|
14275
|
+
if (followed.revisions > 0)
|
|
14276
|
+
progress(`watch: filed ${followed.revisions} CI revision(s)`);
|
|
14277
|
+
if (followed.merged > 0)
|
|
14278
|
+
progress(`watch: merged ${followed.merged} pull request(s) whose checks passed`);
|
|
14279
|
+
},
|
|
14280
|
+
});
|
|
14281
|
+
const poker = new WakePoker();
|
|
14282
|
+
const hub = wakeHubFor(store);
|
|
14283
|
+
const beats = { members: new Set(), stopped: false, delivery, fastCi, poker, wakeLoop: Promise.resolve() };
|
|
14284
|
+
// A wake with new notifications sends them now, not at the next beat.
|
|
14285
|
+
beats.wakeLoop = (async () => {
|
|
14286
|
+
while (!beats.stopped) {
|
|
14287
|
+
try {
|
|
14288
|
+
const why = await hub.changed({ baseline: store.wakeSeq(), deadline: Number.MAX_SAFE_INTEGER, until: () => beats.stopped, poker });
|
|
14289
|
+
if (why === "wake" && !beats.stopped && newestNotificationId(store) > deliveredThrough)
|
|
14290
|
+
void delivery.runNow();
|
|
14291
|
+
}
|
|
14292
|
+
catch {
|
|
14293
|
+
// The store could not be read: the 5 s beat still runs; look again shortly.
|
|
14294
|
+
await new Promise(resolveLater => { const timer = setTimeout(resolveLater, 1_000); timer.unref?.(); });
|
|
14295
|
+
}
|
|
14296
|
+
}
|
|
14297
|
+
})();
|
|
14298
|
+
serviceBeats.set(store, beats);
|
|
14299
|
+
void delivery.runNow();
|
|
14300
|
+
return beats;
|
|
14301
|
+
}
|
package/dist/project-delete.js
CHANGED
|
@@ -239,6 +239,15 @@ function deleteRows(store, repo, d, now) {
|
|
|
239
239
|
del("chat_decide_action", IN("task_id"), K);
|
|
240
240
|
del("chat_decide_prompt", IN("task_id"), K);
|
|
241
241
|
del("chat_message_ref", `${IN("decision")} OR ${IN("task_id")} OR ${IN("run")} OR project = ? OR ${IN("notification")} OR ${IN("task_ref")}`, decisions, K, R, repo, notifications, T);
|
|
242
|
+
// A typed yes to a Start card (one-step-chat.md C5). Its record goes with its message. Where the message stays (it
|
|
243
|
+
// named no task of this project) but the card's proposal goes, the record is settled instead and forgets the task: a
|
|
244
|
+
// yes still waiting is then answered by Toolroll as one that started nothing, never handed to the lead.
|
|
245
|
+
del("chat_typed_start", `provider || ':' || event IN (SELECT value FROM json_each(?))`, conversations);
|
|
246
|
+
if (tableExists(db, "chat_typed_start")) {
|
|
247
|
+
db.prepare(`UPDATE chat_typed_start SET outcome = 'not-counted', task_id = NULL, finished = COALESCE(finished, ?) WHERE ${IN("proposal")}`).run(now.toISOString(), proposals);
|
|
248
|
+
// A yes that started one of this project's tasks, said in a message that stays: the record forgets the task.
|
|
249
|
+
db.prepare(`UPDATE chat_typed_start SET task_id = NULL WHERE ${IN("task_id")}`).run(K);
|
|
250
|
+
}
|
|
242
251
|
del("chat_part", `provider || ':' || id IN (${parts})`, conversations, K, R);
|
|
243
252
|
del("chat_event", `provider || ':' || id IN (SELECT value FROM json_each(?))`, conversations);
|
|
244
253
|
del("chat_card_task", IN("task_ref"), T);
|
|
@@ -22,18 +22,20 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
|
|
|
22
22
|
export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
|
|
23
23
|
/** Chat reads only an admitted project. Keep the full source in the project UI. */
|
|
24
24
|
export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
|
|
25
|
-
|
|
25
|
+
content?: never;
|
|
26
|
+
history?: never;
|
|
26
27
|
revision: number;
|
|
27
28
|
stale: boolean;
|
|
28
29
|
notice: string;
|
|
29
30
|
title?: never;
|
|
30
|
-
content?: never;
|
|
31
|
-
history?: never;
|
|
32
31
|
instructions?: never;
|
|
32
|
+
references?: never;
|
|
33
33
|
decisions?: never;
|
|
34
34
|
waiting?: never;
|
|
35
35
|
} | {
|
|
36
|
-
|
|
36
|
+
content?: never;
|
|
37
|
+
revision?: never;
|
|
38
|
+
history?: never;
|
|
37
39
|
stale?: never;
|
|
38
40
|
decision: {
|
|
39
41
|
repo: string;
|
|
@@ -52,27 +54,25 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
|
|
|
52
54
|
afterwards?: string;
|
|
53
55
|
notice: string;
|
|
54
56
|
title?: never;
|
|
55
|
-
content?: never;
|
|
56
|
-
revision?: never;
|
|
57
|
-
history?: never;
|
|
58
57
|
instructions?: never;
|
|
58
|
+
references?: never;
|
|
59
59
|
decisions?: never;
|
|
60
60
|
waiting?: never;
|
|
61
61
|
} | {
|
|
62
|
-
|
|
62
|
+
history?: never;
|
|
63
63
|
stale?: never;
|
|
64
64
|
revision: number;
|
|
65
65
|
title: string;
|
|
66
66
|
content: string;
|
|
67
67
|
notice: string;
|
|
68
|
-
history?: never;
|
|
69
68
|
instructions?: never;
|
|
69
|
+
references?: never;
|
|
70
70
|
decisions?: never;
|
|
71
71
|
waiting?: never;
|
|
72
72
|
} | {
|
|
73
|
+
content?: never;
|
|
73
74
|
stale?: never;
|
|
74
75
|
title?: never;
|
|
75
|
-
content?: never;
|
|
76
76
|
revision: number;
|
|
77
77
|
history: {
|
|
78
78
|
revision: number;
|
package/dist/publish.d.ts
CHANGED
|
@@ -107,6 +107,8 @@ export type ObservedPullRequest = {
|
|
|
107
107
|
export declare function observeChecks(store: Store, options: {
|
|
108
108
|
clock?: () => Date;
|
|
109
109
|
exec?: PublishExec;
|
|
110
|
+
/** Read only these (the fast pass's waiting pull requests, live-wakeups.md); every one is read exactly as below. */
|
|
111
|
+
publications?: readonly Publication[];
|
|
110
112
|
}): Promise<CheckReport>;
|
|
111
113
|
export declare const MERGE_LEASE_MS: number;
|
|
112
114
|
export declare const FIRING_GRACE_MS: number;
|
package/dist/publish.js
CHANGED
|
@@ -304,7 +304,7 @@ export async function observeChecks(store, options) {
|
|
|
304
304
|
const clock = options.clock ?? (() => new Date());
|
|
305
305
|
const exec = options.exec ?? execRun;
|
|
306
306
|
const report = { observed: 0, failing: 0, resolved: 0, problems: [], seen: [] };
|
|
307
|
-
for (const publication of store.openedPublications()) {
|
|
307
|
+
for (const publication of options.publications ?? store.openedPublications()) {
|
|
308
308
|
if (publication.prNumber === null)
|
|
309
309
|
continue;
|
|
310
310
|
// The observation generation is RESERVED before the network call
|
|
@@ -129,6 +129,8 @@ export declare function followPullRequests(store: Store, seen: readonly Observed
|
|
|
129
129
|
evidenceRoot: string;
|
|
130
130
|
exec?: PublishExec;
|
|
131
131
|
clock?: () => Date;
|
|
132
|
+
/** Asked again before each revision, ready mark and merge: false ends the pass there and writes nothing more. */
|
|
133
|
+
proceed?: () => boolean;
|
|
132
134
|
}): Promise<FollowReport>;
|
|
133
135
|
/** A person's merge: their password first, then the same checked merge. */
|
|
134
136
|
export declare function mergeAsPerson(store: Store, input: {
|
|
@@ -156,6 +158,8 @@ export declare function mergePullRequest(store: Store, input: {
|
|
|
156
158
|
method?: MergeMethod;
|
|
157
159
|
/** Only a person merging by hand may skip waiting for the full check. */
|
|
158
160
|
anyway?: boolean;
|
|
161
|
+
/** An automatic pass's standing, read in the write that reserves the merge: false refuses ("halted") and merges nothing. */
|
|
162
|
+
proceed?: () => boolean;
|
|
159
163
|
}): Promise<Result<{
|
|
160
164
|
commit: string | null;
|
|
161
165
|
}>>;
|
|
@@ -285,7 +285,10 @@ export async function followPullRequests(store, seen, options) {
|
|
|
285
285
|
const exec = options.exec ?? execRun;
|
|
286
286
|
const clock = options.clock ?? (() => new Date());
|
|
287
287
|
const report = { revisions: 0, asked: 0, ready: 0, merged: 0, problems: [] };
|
|
288
|
+
const halted = () => options.proceed?.() === false;
|
|
288
289
|
for (const one of seen) {
|
|
290
|
+
if (halted())
|
|
291
|
+
break;
|
|
289
292
|
const publication = one.publication;
|
|
290
293
|
const follow = followOf(store, publication.id);
|
|
291
294
|
// A flow's Pull request zone follows its own PR: green moves the card on, red takes its failure path.
|
|
@@ -313,6 +316,8 @@ export async function followPullRequests(store, seen, options) {
|
|
|
313
316
|
const check = failingChecks(one.rollup)[0] ?? { name: "a check", url: null };
|
|
314
317
|
const skeleton = ciRevisionNote(check.name, pr, one.headOid, "");
|
|
315
318
|
const excerpt = await logExcerpt(exec, publication.githubRepo, check.url, 490 - skeleton.length);
|
|
319
|
+
if (halted())
|
|
320
|
+
break;
|
|
316
321
|
const note = ciRevisionNote(check.name, pr, one.headOid, excerpt);
|
|
317
322
|
const request = createHash("sha256").update(`ci-revision ${publication.githubRepo} ${pr} ${one.headOid}`).digest("hex").slice(0, 32);
|
|
318
323
|
const scope = store.getScope(taskId);
|
|
@@ -348,11 +353,17 @@ export async function followPullRequests(store, seen, options) {
|
|
|
348
353
|
const grant = store.publicationGrantFor(store.refById(publication.taskRef)?.repo ?? "");
|
|
349
354
|
updateFollow(store, publication.id, { ready_head: one.headOid }, clock());
|
|
350
355
|
if (grant?.mergeWhenGreen === true && grant.publishOn === "complete") {
|
|
351
|
-
const merged = await mergePullRequest(store, { runId: publication.run, by: "merge when checks pass", exec, clock
|
|
356
|
+
const merged = await mergePullRequest(store, { runId: publication.run, by: "merge when checks pass", exec, clock,
|
|
357
|
+
...(options.proceed === undefined ? {} : { proceed: options.proceed }) });
|
|
352
358
|
if (merged.ok) {
|
|
353
359
|
report.merged++;
|
|
354
360
|
continue;
|
|
355
361
|
}
|
|
362
|
+
// Halted before the merge was reserved: the ready mark is taken back so the next pass merges it, and nobody is asked to.
|
|
363
|
+
if (merged.reason === "halted") {
|
|
364
|
+
updateFollow(store, publication.id, { ready_head: follow.readyHead }, clock());
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
356
367
|
report.problems.push(`PR #${pr}: ${merged.message}`);
|
|
357
368
|
}
|
|
358
369
|
store.enqueueNotification({
|
|
@@ -485,6 +496,8 @@ export async function mergePullRequest(store, input) {
|
|
|
485
496
|
// One merge at a time, each owning its reservation by its token: a second caller is refused, and each releases only its own.
|
|
486
497
|
const token = randomUUID();
|
|
487
498
|
const reserved = store.transact(() => {
|
|
499
|
+
if (input.proceed?.() === false)
|
|
500
|
+
return "halted";
|
|
488
501
|
if (refArchived(store, publication.taskRef))
|
|
489
502
|
return "archived";
|
|
490
503
|
if (store.handle.prepare("SELECT 1 FROM hold WHERE task_ref = ? AND owner_id LIKE 'project-delete:%' AND (until IS NULL OR until > ?)").get(publication.taskRef, clock().toISOString()) !== undefined)
|
|
@@ -494,6 +507,8 @@ export async function mergePullRequest(store, input) {
|
|
|
494
507
|
store.handle.prepare("INSERT OR REPLACE INTO merge_in_flight (publication, task_ref, token, by, at) VALUES (?, ?, ?, ?, ?)").run(publication.id, publication.taskRef, token, input.by, clock().toISOString());
|
|
495
508
|
return "ok";
|
|
496
509
|
});
|
|
510
|
+
if (reserved === "halted")
|
|
511
|
+
return refuse("halted", "The worker is stopping or paused, so this merge waits for its next pass.");
|
|
497
512
|
if (reserved === "archived")
|
|
498
513
|
return refuse("archived", "This task is put away. Restore it before merging.");
|
|
499
514
|
if (reserved === "deleting")
|
package/dist/reply-shape.d.ts
CHANGED
|
@@ -35,6 +35,15 @@ export declare function linkLabel(url: string, appOrigin?: ShapeOptions["appOrig
|
|
|
35
35
|
* changes nothing.
|
|
36
36
|
*/
|
|
37
37
|
export declare function shapeReply(text: string, options?: ShapeOptions): string;
|
|
38
|
+
/**
|
|
39
|
+
* A reply still being written, shaped with the final reply's presentation policy (docs/plans/responsive-chat.md §2.2),
|
|
40
|
+
* except that ids are dropped unless the owner asked for them: whether a reply names several of a kind isn't known
|
|
41
|
+
* until it ends, so a prefix never keeps them on that ground. Presentation, not redaction.
|
|
42
|
+
*/
|
|
43
|
+
export declare function shapeReplyPrefix(text: string, options?: ShapeOptions): string;
|
|
44
|
+
/** The first window of a shaped text of at most `size` characters, cut where a part would be (never inside code, a link,
|
|
45
|
+
* a bold anchor or an emoji's two halves). A single link longer than the window stays whole, so the caller measures. */
|
|
46
|
+
export declare function shapedWindow(shaped: string, size: number): string;
|
|
38
47
|
/**
|
|
39
48
|
* A long reply split into parts of at most `size` characters, then each part shaped: the cut is made on the reply as
|
|
40
49
|
* written, at a paragraph or line where possible and never inside code, a link or a bold anchor, so no part carries
|