omp-conductor 0.20.0 → 0.20.2
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/package.json +1 -1
- package/schema/config.schema.json +24 -0
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +54 -3
- package/src/briefs/console.md +10 -5
- package/src/commands/arm.ts +7 -5
- package/src/commands/companion.ts +52 -16
- package/src/commands/drain.ts +12 -5
- package/src/commands/worker.ts +5 -0
- package/src/config-schema.ts +8 -0
- package/src/config.ts +17 -2
- package/src/daemon/drain.ts +33 -0
- package/src/daemon/groom-pass.ts +16 -6
- package/src/daemon/http.ts +28 -0
- package/src/daemon/review.ts +11 -11
- package/src/daemon/runtime.ts +55 -3
- package/src/daemon/settle-pass.ts +19 -2
- package/src/daemon/supervision.ts +144 -1
- package/src/daemon/tick.ts +31 -14
- package/src/daemon/views.ts +17 -0
- package/src/daemon.ts +4 -3
- package/src/decisions.ts +20 -9
- package/src/diff-flags.ts +204 -28
- package/src/doctor.ts +27 -9
- package/src/escalate.ts +187 -15
- package/src/failure-class.ts +358 -5
- package/src/fleet.ts +55 -25
- package/src/graph-health.ts +17 -1
- package/src/groom.ts +11 -0
- package/src/orchestrator-tick.ts +302 -24
- package/src/reports.ts +4 -1
- package/src/settlement.ts +133 -12
- package/src/status-render.ts +38 -9
- package/src/store.ts +64 -15
- package/src/to-spec.ts +285 -24
- package/src/tracker/github.ts +132 -10
- package/src/types.ts +49 -3
- package/src/verbs/server.ts +16 -12
- package/src/worker.ts +149 -35
package/src/graph-health.ts
CHANGED
|
@@ -52,7 +52,10 @@ export type CodeGraphHealth =
|
|
|
52
52
|
active: "active" | "inactive" | "unknown";
|
|
53
53
|
};
|
|
54
54
|
refresh: {
|
|
55
|
-
|
|
55
|
+
/** "running": the oneshot is executing right now (#1090). systemd has
|
|
56
|
+
* already cleared the previous exit timestamp mid-run, so this is a
|
|
57
|
+
* progress report — never a claim about staleness or absence. */
|
|
58
|
+
result: "success" | "failed" | "running" | "unknown";
|
|
56
59
|
fresh: boolean | null;
|
|
57
60
|
lastSuccessAt?: string;
|
|
58
61
|
ageMs?: number;
|
|
@@ -208,6 +211,12 @@ function refreshResult(result: ReadOnlyCommandResult, now: number): RefreshResul
|
|
|
208
211
|
const serviceResult = fields.get("Result");
|
|
209
212
|
const exitStatus = fields.get("ExecMainStatus");
|
|
210
213
|
const completedAt = fields.get("ExecMainExitTimestamp");
|
|
214
|
+
// #1090: an empty ExecMainExitTimestamp says "no completed run yet", which
|
|
215
|
+
// is equally true of a unit that has never run and of one executing right
|
|
216
|
+
// now — systemd clears it when the next run starts. ActiveState comes back
|
|
217
|
+
// from the same single `systemctl show` and separates the two; evidence
|
|
218
|
+
// captured without the property keeps the conservative pre-#1090 reading.
|
|
219
|
+
const activeState = fields.get("ActiveState");
|
|
211
220
|
if (serviceResult === undefined || exitStatus === undefined || completedAt === undefined) {
|
|
212
221
|
return unknown("refresh service returned an unrecognized result", true);
|
|
213
222
|
}
|
|
@@ -219,6 +228,12 @@ function refreshResult(result: ReadOnlyCommandResult, now: number): RefreshResul
|
|
|
219
228
|
};
|
|
220
229
|
}
|
|
221
230
|
if (completedAt === "" || completedAt === "n/a") {
|
|
231
|
+
if (activeState === "activating" || activeState === "active") {
|
|
232
|
+
// There is no completed timestamp to measure staleness against yet, but
|
|
233
|
+
// this is progress, not absence: the reindex is keeping the graph fresh
|
|
234
|
+
// at this moment, so neither the row nor the previous message degrades.
|
|
235
|
+
return { health: { result: "running", fresh: null }, uncertain: false };
|
|
236
|
+
}
|
|
222
237
|
return unknown("no successful refresh has been recorded", false);
|
|
223
238
|
}
|
|
224
239
|
const completedMs = Date.parse(completedAt);
|
|
@@ -290,6 +305,7 @@ export async function probeCodeGraph(
|
|
|
290
305
|
"--property=Result",
|
|
291
306
|
"--property=ExecMainStatus",
|
|
292
307
|
"--property=ExecMainExitTimestamp",
|
|
308
|
+
"--property=ActiveState",
|
|
293
309
|
]),
|
|
294
310
|
]);
|
|
295
311
|
|
package/src/groom.ts
CHANGED
|
@@ -114,6 +114,17 @@ export interface ReadyGateRejection {
|
|
|
114
114
|
* {@link toSpecDurableVerdict} still recovers the verdict and the candidate is
|
|
115
115
|
* not re-groomed — the spec is rejected, not ungroomed.
|
|
116
116
|
*
|
|
117
|
+
* That durability is bounded by the verdict itself (#1060). A rejection
|
|
118
|
+
* beside a verdict the contract still accepts is not re-groomable, because
|
|
119
|
+
* the fault lies in the issue — missing criteria, a missing lane — and only
|
|
120
|
+
* an edit to the issue can fix it. A rejection beside a verdict the contract
|
|
121
|
+
* refuses — veltro#731's prose lane entry, persisted before parse-time lane
|
|
122
|
+
* validation — reads as not-durable through {@link toSpecDurableVerdict},
|
|
123
|
+
* and the candidate becomes re-groomable: no issue edit can ever satisfy a
|
|
124
|
+
* gate whose refusal is baked into the verdict, and complying with the
|
|
125
|
+
* refusal's remedy would write the unusable entry into the issue itself, so
|
|
126
|
+
* a fresh pass is the only repair.
|
|
127
|
+
*
|
|
117
128
|
* Fails closed on anything it cannot read: an empty `missing` array is not a
|
|
118
129
|
* rejection (a gate that found nothing missing passed), and a non-string entry
|
|
119
130
|
* would render as `undefined` in a digest line an operator is asked to act on.
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
|
|
47
47
|
import { createHash, randomUUID } from "node:crypto";
|
|
48
48
|
import { spawnSync } from "node:child_process";
|
|
49
|
-
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
49
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
50
50
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
51
51
|
import { availabilityPrompt, interruptDisposition } from "./availability.ts";
|
|
52
52
|
import {
|
|
@@ -132,6 +132,20 @@ import {
|
|
|
132
132
|
type ToSpecTrackerSeam,
|
|
133
133
|
} from "./groom.ts";
|
|
134
134
|
import { heldNoticeId } from "./notices.ts";
|
|
135
|
+
import {
|
|
136
|
+
clearArmTransaction,
|
|
137
|
+
expiredArmChallenges,
|
|
138
|
+
FLEET_ARM_KEY,
|
|
139
|
+
resolveArmReply,
|
|
140
|
+
type ArmChallengeSighting,
|
|
141
|
+
type ArmReplyMatch,
|
|
142
|
+
} from "./arm-challenge.ts";
|
|
143
|
+
import {
|
|
144
|
+
readTelegramChannel,
|
|
145
|
+
readTelegramToken,
|
|
146
|
+
resolveProjectTopicId,
|
|
147
|
+
sendTelegram,
|
|
148
|
+
} from "./escalate.ts";
|
|
135
149
|
|
|
136
150
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
137
151
|
export const TICK_CONFIG_FILE = ".conductor-tick.json";
|
|
@@ -489,6 +503,10 @@ const AUTONOMOUS_RECOVERY_ACTIONS: Record<RecoveryAction, boolean> = {
|
|
|
489
503
|
"rerun-checks": true,
|
|
490
504
|
settle: true,
|
|
491
505
|
escalate: false,
|
|
506
|
+
// An observation row never stamps `recoveredAt` (the recovery IS the later
|
|
507
|
+
// sweep pass), so it never lands in this digest; `false` is the honest value
|
|
508
|
+
// if one ever did — it is not "already handled", it is waiting (#1068).
|
|
509
|
+
observe: false,
|
|
492
510
|
hold: false,
|
|
493
511
|
none: false,
|
|
494
512
|
};
|
|
@@ -1638,6 +1656,20 @@ export function resolveArmState(armedFile: string, projectName?: string): ArmSta
|
|
|
1638
1656
|
return shared ? { armed: true } : { armed: true, legacy: "honoured" };
|
|
1639
1657
|
}
|
|
1640
1658
|
|
|
1659
|
+
/**
|
|
1660
|
+
* The one armed-marker write both settlement paths share — the CLI's
|
|
1661
|
+
* `arm --reply` half and the orchestrator session's mechanical reply consumer
|
|
1662
|
+
* (#1061): same content, same mode, and the same restamp of the pre-per-project
|
|
1663
|
+
* shared marker the heartbeat still honours (#316). It lives here, beside
|
|
1664
|
+
* {@link resolveArmState}, so the session consumer can write the gate it reads
|
|
1665
|
+
* — the reverse import (into `fleet.ts`) would cycle.
|
|
1666
|
+
*/
|
|
1667
|
+
export function writeArmedMarker(path: string, owner: string, arm: ArmState): void {
|
|
1668
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1669
|
+
writeFileSync(path, `armed ${new Date().toISOString()} owner=${owner}\n`, { mode: 0o600 });
|
|
1670
|
+
if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1641
1673
|
/**
|
|
1642
1674
|
* Best-effort recompose of `ORCHESTRATOR.md` from the installed package floor +
|
|
1643
1675
|
* live `POLICY.md`.
|
|
@@ -2854,6 +2886,7 @@ async function tick(
|
|
|
2854
2886
|
config: TickConfig,
|
|
2855
2887
|
session: TickSession,
|
|
2856
2888
|
toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
|
|
2889
|
+
armSeam: ArmConsumerSeam | undefined,
|
|
2857
2890
|
): Promise<void> {
|
|
2858
2891
|
const live = currentConfig(ctx.cwd, config);
|
|
2859
2892
|
const arm = live.armedFile === undefined ? undefined : resolveArmState(live.armedFile, live.project);
|
|
@@ -2863,6 +2896,29 @@ async function tick(
|
|
|
2863
2896
|
`[omp-conductor] ${legacyArmedMarkerPath()}: ${LEGACY_ARM_MARKER_DETAIL} — this heartbeat stays disarmed`,
|
|
2864
2897
|
);
|
|
2865
2898
|
}
|
|
2899
|
+
// An unanswered ceremony that passed its window is a dead ceremony, not a
|
|
2900
|
+
// silent one (#1061): the tick that notices tells the operator why and how
|
|
2901
|
+
// to re-run it, on the surface the challenge was sent to. Deliberately
|
|
2902
|
+
// before the tick gate — a disarmed fleet is exactly the state an
|
|
2903
|
+
// unanswered challenge leaves, and its death notice must not wait for an
|
|
2904
|
+
// arm to be heard. The scan is synchronous so an idle host (no expired
|
|
2905
|
+
// records) costs the tick nothing; only a send is awaited.
|
|
2906
|
+
const armNow = (armSeam?.now ?? Date.now)();
|
|
2907
|
+
for (const expired of expiredArmChallenges(live.project, armNow)) {
|
|
2908
|
+
const surface: ArmNoticeSurface = { accessFile: live.accessFile, project: live.project };
|
|
2909
|
+
const send = armSeam?.notify ?? sendArmNotice;
|
|
2910
|
+
try {
|
|
2911
|
+
await send(armExpiryNotice(expired, live.project), surface);
|
|
2912
|
+
} catch (err) {
|
|
2913
|
+
// Keep the record: the next heartbeat retries, and `doctor` still
|
|
2914
|
+
// reports it.
|
|
2915
|
+
pi.logger.error(
|
|
2916
|
+
`[omp-conductor] arm expiry notice not delivered: ${err instanceof Error ? err.message : String(err)}`,
|
|
2917
|
+
);
|
|
2918
|
+
continue;
|
|
2919
|
+
}
|
|
2920
|
+
clearArmTransaction(expired.key, expired.id);
|
|
2921
|
+
}
|
|
2866
2922
|
const decision = tickDecision({
|
|
2867
2923
|
armed: arm === undefined || arm.armed,
|
|
2868
2924
|
channelOk: live.accessFile === undefined || channelIsUp(live.accessFile),
|
|
@@ -3371,11 +3427,12 @@ function armTickHeartbeat(
|
|
|
3371
3427
|
config: TickConfig,
|
|
3372
3428
|
session: TickSession,
|
|
3373
3429
|
toSpecTrackerSeam: ToSpecTrackerSeam | undefined,
|
|
3430
|
+
armSeam: ArmConsumerSeam | undefined,
|
|
3374
3431
|
): void {
|
|
3375
3432
|
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
3376
3433
|
const runScheduledTick = async (): Promise<void> => {
|
|
3377
3434
|
try {
|
|
3378
|
-
await tick(pi, ctx, config, session, toSpecTrackerSeam);
|
|
3435
|
+
await tick(pi, ctx, config, session, toSpecTrackerSeam, armSeam);
|
|
3379
3436
|
} finally {
|
|
3380
3437
|
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
3381
3438
|
}
|
|
@@ -3431,7 +3488,7 @@ function armTickHeartbeat(
|
|
|
3431
3488
|
// call runs inside the session_start handler dispatch, which cannot see an
|
|
3432
3489
|
// async rejection — an escaped one reaches the process-level
|
|
3433
3490
|
// unhandledRejection handler and takes the session down.
|
|
3434
|
-
void tick(pi, ctx, config, session, toSpecTrackerSeam).catch((err) => {
|
|
3491
|
+
void tick(pi, ctx, config, session, toSpecTrackerSeam, armSeam).catch((err) => {
|
|
3435
3492
|
pi.logger.error(
|
|
3436
3493
|
`[omp-conductor] arm-time tick failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
3437
3494
|
);
|
|
@@ -3496,6 +3553,215 @@ export interface OrchestratorTickExtensionOptions {
|
|
|
3496
3553
|
* present-tense empty queue.
|
|
3497
3554
|
*/
|
|
3498
3555
|
toSpec?: { tracker?: ToSpecTrackerSeam };
|
|
3556
|
+
/**
|
|
3557
|
+
* The arm ceremony's mechanical consumer seams (#1061). Production omits
|
|
3558
|
+
* them and the extension classifies inbound turns against the pending
|
|
3559
|
+
* challenge records itself, sending confirmations and expiry notices over
|
|
3560
|
+
* the same bridge transport `armTicks` sends the challenge on. Tests inject
|
|
3561
|
+
* a recorder (`notify`) and a fixed clock (`now`) so the settle and the
|
|
3562
|
+
* notices are provable without Telegram or timers.
|
|
3563
|
+
*/
|
|
3564
|
+
arm?: {
|
|
3565
|
+
now?: () => number;
|
|
3566
|
+
notify?: ArmNoticeFn;
|
|
3567
|
+
};
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
/** The send surface an arm notice rides: the live tick config's channel and project. */
|
|
3571
|
+
export interface ArmNoticeSurface {
|
|
3572
|
+
accessFile?: string;
|
|
3573
|
+
project?: string;
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
/**
|
|
3577
|
+
* One mechanical Telegram send the arm consumer raises: the confirmation when
|
|
3578
|
+
* a reply settles the ceremony, or the expiry notice when a challenge dies
|
|
3579
|
+
* unanswered. The default is {@link sendArmNotice}; tests record instead.
|
|
3580
|
+
*/
|
|
3581
|
+
export type ArmNoticeFn = (text: string, surface: ArmNoticeSurface) => Promise<void> | void;
|
|
3582
|
+
|
|
3583
|
+
/** The consumer's injectable clock and transport, carried into the tick's helpers. */
|
|
3584
|
+
export interface ArmConsumerSeam {
|
|
3585
|
+
now?: () => number;
|
|
3586
|
+
notify?: ArmNoticeFn;
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
/**
|
|
3590
|
+
* The plain text of an inbound message, for the arm classifier. Content blocks
|
|
3591
|
+
* join with newlines so a code split across block boundaries still tokenises
|
|
3592
|
+
* whole; non-text blocks contribute nothing. `undefined` for an absent or
|
|
3593
|
+
* empty message, which is ordinary chat either way.
|
|
3594
|
+
*/
|
|
3595
|
+
function inboundMessageText(content: readonly { type?: string; text?: string }[] | undefined): string | undefined {
|
|
3596
|
+
if (content === undefined || content.length === 0) return undefined;
|
|
3597
|
+
const text = content.map((block) => block.text ?? "").join("\n");
|
|
3598
|
+
return text.length === 0 ? undefined : text;
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
/**
|
|
3602
|
+
* The ready-to-send expiry notice: the reason — never answered, or answered
|
|
3603
|
+
* but never settled — and the exact command that re-runs the ceremony. The
|
|
3604
|
+
* code is the proof and is never named in a notice; only the window is.
|
|
3605
|
+
*/
|
|
3606
|
+
function armExpiryNotice(expired: { key: string; sighting: ArmChallengeSighting }, project: string | undefined): string {
|
|
3607
|
+
const reissue =
|
|
3608
|
+
expired.key === FLEET_ARM_KEY || project === undefined || project === ""
|
|
3609
|
+
? "omp-conductor arm"
|
|
3610
|
+
: `omp-conductor arm --project ${project}`;
|
|
3611
|
+
const sentAt =
|
|
3612
|
+
expired.sighting.sentAt === undefined
|
|
3613
|
+
? ""
|
|
3614
|
+
: ` sent ${new Date(expired.sighting.sentAt).toISOString()}`;
|
|
3615
|
+
const reason =
|
|
3616
|
+
expired.sighting.acknowledgedAt === undefined
|
|
3617
|
+
? `The arming challenge${sentAt} expired without being answered — nothing is armed.`
|
|
3618
|
+
: `The arming challenge${sentAt} was answered but never settled before its window closed — nothing is armed.`;
|
|
3619
|
+
return `${reason} Send a fresh one: ${reissue}`;
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
/**
|
|
3623
|
+
* The default transport for the arm consumer's confirmation and expiry
|
|
3624
|
+
* notices: the same bot token, paired channel and project topic `armTicks`
|
|
3625
|
+
* sends the challenge on, so the notice lands on the surface the operator was
|
|
3626
|
+
* asked in. Deliberately not availability-gated — the ceremony is the
|
|
3627
|
+
* operator's own interaction, exactly like the challenge send itself, and a
|
|
3628
|
+
* notice held for quiet hours would be the silent failure #1061 removes. Any
|
|
3629
|
+
* missing or down fact throws; the caller logs and keeps the challenge record
|
|
3630
|
+
* so the next heartbeat retries and `doctor` still reports it.
|
|
3631
|
+
*/
|
|
3632
|
+
export async function sendArmNotice(text: string, surface: ArmNoticeSurface): Promise<void> {
|
|
3633
|
+
if (surface.accessFile === undefined) {
|
|
3634
|
+
throw new Error("no accessFile — the arm notice has no channel");
|
|
3635
|
+
}
|
|
3636
|
+
const channel = readTelegramChannel(surface.accessFile);
|
|
3637
|
+
if (channel.kind === "down") {
|
|
3638
|
+
throw new Error(`escalation channel is not up (${surface.accessFile}): ${channel.reason}`);
|
|
3639
|
+
}
|
|
3640
|
+
const token = readTelegramToken();
|
|
3641
|
+
if (token === undefined) {
|
|
3642
|
+
throw new Error("no Telegram bot token readable — the arm notice cannot send");
|
|
3643
|
+
}
|
|
3644
|
+
let topicId: number | undefined;
|
|
3645
|
+
let project: { name: string; workspaceRoot?: string } | undefined;
|
|
3646
|
+
if (surface.project !== undefined) {
|
|
3647
|
+
try {
|
|
3648
|
+
const p = findProject(loadConfig(), surface.project);
|
|
3649
|
+
topicId = resolveProjectTopicId(p);
|
|
3650
|
+
project = { name: p.name, workspaceRoot: p.workspaceRoot };
|
|
3651
|
+
} catch {
|
|
3652
|
+
/* no project config — flat chat, exactly like armTicks */
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
await sendTelegram(token, channel.owner, text, {
|
|
3656
|
+
topicId,
|
|
3657
|
+
...(project === undefined ? {} : { project }),
|
|
3658
|
+
});
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
/**
|
|
3662
|
+
* The settle half of the mechanical consumer: write the markers the matched
|
|
3663
|
+
* challenge recorded and clear its transaction, then confirm on the
|
|
3664
|
+
* challenge's surface. The recorded targets are the contract — the exact rule
|
|
3665
|
+
* `armReply` applies — so nothing is ever armed that the challenge did not
|
|
3666
|
+
* name. A pre-targets record (one release of overlap) is not settled here:
|
|
3667
|
+
* nothing in-session may guess a marker the challenge never recorded; the CLI
|
|
3668
|
+
* half still settles it, and the operator is told so.
|
|
3669
|
+
*/
|
|
3670
|
+
function settleInboundArmMatch(
|
|
3671
|
+
pi: TickApi,
|
|
3672
|
+
match: ArmReplyMatch,
|
|
3673
|
+
notify: (text: string) => void,
|
|
3674
|
+
): void {
|
|
3675
|
+
if (match.targets === undefined || match.owner === undefined) {
|
|
3676
|
+
pi.logger.error(
|
|
3677
|
+
"[omp-conductor] an inbound arming code matched a pre-targets challenge — nothing armed in-session; " +
|
|
3678
|
+
'run `omp-conductor arm --reply "<the reply>"` to settle it',
|
|
3679
|
+
);
|
|
3680
|
+
return;
|
|
3681
|
+
}
|
|
3682
|
+
for (const target of match.targets) {
|
|
3683
|
+
const state = resolveArmState(target.armedFile, target.project);
|
|
3684
|
+
writeArmedMarker(target.armedFile, match.owner, state);
|
|
3685
|
+
}
|
|
3686
|
+
clearArmTransaction(match.key, match.id);
|
|
3687
|
+
const label =
|
|
3688
|
+
match.targets.length === 1 && match.targets[0]!.project !== undefined
|
|
3689
|
+
? `project ${match.targets[0]!.project}`
|
|
3690
|
+
: `${match.targets.length} project(s)`;
|
|
3691
|
+
notify(`Arming verified — ${label} armed. The reply was consumed; nothing else to do.`);
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
/**
|
|
3695
|
+
* The arm ceremony's mechanical consumer (#1061): classify one inbound user
|
|
3696
|
+
* turn against this host's pending challenges and, on a match, settle the
|
|
3697
|
+
* ceremony exactly as `omp-conductor arm --reply` would — markers for the
|
|
3698
|
+
* recorded targets, transaction cleared, a confirmation on the surface the
|
|
3699
|
+
* challenge was sent to. This is the real owner the ceremony needed on a host
|
|
3700
|
+
* with no console session: the challenge goes to the project topic, which is
|
|
3701
|
+
* this pane's own claimed surface, so the reply lands here and the ceremony
|
|
3702
|
+
* completes with no second human command. A code past its window is answered
|
|
3703
|
+
* with the re-run command and the dead record is cleared; a lookalike matching
|
|
3704
|
+
* nothing stays inert (#415's non-disclosure bound is unchanged).
|
|
3705
|
+
*
|
|
3706
|
+
* Never throws: an inbound message must not take the session down. Failures
|
|
3707
|
+
* are logged and the CLI half of the ceremony stays available.
|
|
3708
|
+
*/
|
|
3709
|
+
function consumeInboundArmReply(
|
|
3710
|
+
pi: TickApi,
|
|
3711
|
+
cwd: string,
|
|
3712
|
+
startupProject: string | undefined,
|
|
3713
|
+
text: string,
|
|
3714
|
+
seam: ArmConsumerSeam | undefined,
|
|
3715
|
+
): void {
|
|
3716
|
+
// The state key the send half derived from the same file, read fresh so a
|
|
3717
|
+
// re-stamp between the send and the reply cannot split the ceremony. An
|
|
3718
|
+
// invalid config cannot be trusted to name either the key or a marker —
|
|
3719
|
+
// fail closed exactly like `armReply`, but without throwing at a message.
|
|
3720
|
+
const reread = readTickConfig(cwd);
|
|
3721
|
+
if (reread.kind === "invalid") {
|
|
3722
|
+
pi.logger.error(
|
|
3723
|
+
`[omp-conductor] inbound arm reply not classified — tick config invalid at ${reread.path}: ` +
|
|
3724
|
+
`${reread.problem}; use \`omp-conductor arm --reply\` on the host`,
|
|
3725
|
+
);
|
|
3726
|
+
return;
|
|
3727
|
+
}
|
|
3728
|
+
const project = reread.kind === "ok" ? reread.config.project : startupProject;
|
|
3729
|
+
const surface: ArmNoticeSurface = {
|
|
3730
|
+
...(reread.kind === "ok" ? { accessFile: reread.config.accessFile } : {}),
|
|
3731
|
+
project,
|
|
3732
|
+
};
|
|
3733
|
+
const now = (seam?.now ?? Date.now)();
|
|
3734
|
+
const resolved = resolveArmReply(project, text, now);
|
|
3735
|
+
const notify = (notice: string): void => {
|
|
3736
|
+
const send = seam?.notify ?? sendArmNotice;
|
|
3737
|
+
try {
|
|
3738
|
+
// The send is async on the production path; message_start handlers are
|
|
3739
|
+
// not, so delivery rides the microtask queue and its failure lands in
|
|
3740
|
+
// the log, never on the session.
|
|
3741
|
+
void Promise.resolve(send(notice, surface)).catch((err: unknown) => {
|
|
3742
|
+
pi.logger.error(
|
|
3743
|
+
`[omp-conductor] arm notice not delivered: ${err instanceof Error ? err.message : String(err)}`,
|
|
3744
|
+
);
|
|
3745
|
+
});
|
|
3746
|
+
} catch (err) {
|
|
3747
|
+
pi.logger.error(
|
|
3748
|
+
`[omp-conductor] arm notice not delivered: ${err instanceof Error ? err.message : String(err)}`,
|
|
3749
|
+
);
|
|
3750
|
+
}
|
|
3751
|
+
};
|
|
3752
|
+
if (resolved.verdict === "matched") {
|
|
3753
|
+
settleInboundArmMatch(pi, resolved.match, notify);
|
|
3754
|
+
return;
|
|
3755
|
+
}
|
|
3756
|
+
// A code past its window: the ceremony is dead, so answer the person who is
|
|
3757
|
+
// clearly trying to complete it — and clear the expired record so neither
|
|
3758
|
+
// this path nor the heartbeat ever notifies twice.
|
|
3759
|
+
if (resolved.verdict === "expired") {
|
|
3760
|
+
for (const expired of expiredArmChallenges(project, now)) {
|
|
3761
|
+
notify(armExpiryNotice(expired, project));
|
|
3762
|
+
clearArmTransaction(expired.key, expired.id);
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3499
3765
|
}
|
|
3500
3766
|
|
|
3501
3767
|
export default function orchestratorTickExtension(
|
|
@@ -3634,8 +3900,10 @@ export default function orchestratorTickExtension(
|
|
|
3634
3900
|
|
|
3635
3901
|
/** `configuredProject` carries {@link TickConfig.project} for the same reason
|
|
3636
3902
|
* {@link armReleaseGate} takes it: a recovered tick must reconstruct *this*
|
|
3637
|
-
* fleet's availability policy, not refuse to guess between two projects.
|
|
3638
|
-
|
|
3903
|
+
* fleet's availability policy, not refuse to guess between two projects.
|
|
3904
|
+
* `cwd` is the fleet directory itself, which the arm consumer re-reads the
|
|
3905
|
+
* tick config from at reply time (#1061). */
|
|
3906
|
+
const armAvailabilityGate = (configuredProject?: string, cwd?: string): void => {
|
|
3639
3907
|
if (availabilityGateArmed) return;
|
|
3640
3908
|
availabilityGateArmed = true;
|
|
3641
3909
|
|
|
@@ -3667,23 +3935,33 @@ export default function orchestratorTickExtension(
|
|
|
3667
3935
|
}
|
|
3668
3936
|
return;
|
|
3669
3937
|
}
|
|
3670
|
-
// An inbound user turn on THIS session is
|
|
3938
|
+
// An inbound user turn on THIS session is an anomaly, not a
|
|
3671
3939
|
// conversation: operator DMs belong to the 24/7 console session, so a
|
|
3672
|
-
// turn arriving here is an accidental topic post.
|
|
3673
|
-
//
|
|
3674
|
-
//
|
|
3675
|
-
//
|
|
3676
|
-
// (
|
|
3677
|
-
//
|
|
3678
|
-
//
|
|
3679
|
-
//
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3940
|
+
// turn arriving here is an accidental topic post. Two things happen,
|
|
3941
|
+
// and both are mechanisms — nothing here is left to model behaviour:
|
|
3942
|
+
//
|
|
3943
|
+
// 1. The arm ceremony's reply is classified against the pending
|
|
3944
|
+
// challenge records (#1061). The pre-#415 tombstone is retired: it
|
|
3945
|
+
// reasoned the reply lands "where no tick extension runs", but the
|
|
3946
|
+
// send half delivers the challenge to the project topic — this
|
|
3947
|
+
// pane's own claimed surface — so the reply lands exactly here, and
|
|
3948
|
+
// on a host with no console session the CLI's `arm --reply` step had
|
|
3949
|
+
// no owner at all. A matching code settles the ceremony
|
|
3950
|
+
// (markers written, transaction cleared) and answers with a
|
|
3951
|
+
// confirmation; a code past its window is answered with the re-run
|
|
3952
|
+
// command; a lookalike matching nothing stays inert, exactly as
|
|
3953
|
+
// #415 required.
|
|
3954
|
+
// 2. {@link tickGuardDecision}'s preemption, unchanged: a person who
|
|
3955
|
+
// posts into the fleet topic by accident is still a person waiting,
|
|
3956
|
+
// and the autonomous-interrupt refusal must not fire at them.
|
|
3957
|
+
if (message.role === "user" && message.synthetic !== true && message.attribution !== "agent") {
|
|
3958
|
+
if (cwd !== undefined) {
|
|
3959
|
+
const text = inboundMessageText(message.content);
|
|
3960
|
+
if (text !== undefined) consumeInboundArmReply(pi, cwd, configuredProject, text, options.arm);
|
|
3961
|
+
}
|
|
3962
|
+
if (session.activeLocalTick !== undefined) {
|
|
3963
|
+
session.activeLocalTick.humanWaiting = true;
|
|
3964
|
+
}
|
|
3687
3965
|
}
|
|
3688
3966
|
});
|
|
3689
3967
|
pi.on("agent_end", () => {
|
|
@@ -3921,7 +4199,7 @@ export default function orchestratorTickExtension(
|
|
|
3921
4199
|
// invalid config names none — it cannot be trusted to.
|
|
3922
4200
|
const configuredProject = result.kind === "ok" ? result.config.project : undefined;
|
|
3923
4201
|
armReleaseGate(configuredProject);
|
|
3924
|
-
armAvailabilityGate(configuredProject);
|
|
4202
|
+
armAvailabilityGate(configuredProject, ctx.cwd);
|
|
3925
4203
|
// The tool is already registered (extension-factory time); only the session
|
|
3926
4204
|
// state it acts on is filled, and only once ownership is accepted below.
|
|
3927
4205
|
// `configuredProject` isn't closed over at all — routing is re-read from
|
|
@@ -4011,7 +4289,7 @@ export default function orchestratorTickExtension(
|
|
|
4011
4289
|
// not create or deliver decisions). `cwd` is for re-reading the live
|
|
4012
4290
|
// tick config at execution, `config` for the startup-only ceiling.
|
|
4013
4291
|
askSession = { cwd: ctx.cwd, config };
|
|
4014
|
-
armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
|
|
4292
|
+
armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam, options.arm);
|
|
4015
4293
|
if (!guardArmed) {
|
|
4016
4294
|
guardArmed = true;
|
|
4017
4295
|
armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
|
|
@@ -4033,7 +4311,7 @@ export default function orchestratorTickExtension(
|
|
|
4033
4311
|
// why): a declined or unresolved session keeps `askSession` undefined and
|
|
4034
4312
|
// the tool fails closed.
|
|
4035
4313
|
askSession = { cwd: ctx.cwd, config };
|
|
4036
|
-
armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam);
|
|
4314
|
+
armTickHeartbeat(pi, ctx, config, session, toSpecTrackerSeam, options.arm);
|
|
4037
4315
|
if (!guardArmed) {
|
|
4038
4316
|
guardArmed = true;
|
|
4039
4317
|
armTickGuard(pi, ctx, (config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1000);
|
package/src/reports.ts
CHANGED
|
@@ -361,7 +361,10 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
|
|
|
361
361
|
// limit would still be delivered whole (never truncated) — the seam would
|
|
362
362
|
// simply under-report the extra ids, which is why the split lives at the
|
|
363
363
|
// caller, not here.
|
|
364
|
-
const ids = await sendTelegram(token, chatId, text, {
|
|
364
|
+
const ids = await sendTelegram(token, chatId, text, {
|
|
365
|
+
topicId: resolveProjectTopicId(p),
|
|
366
|
+
project: { name: p.name, workspaceRoot: p.workspaceRoot },
|
|
367
|
+
});
|
|
365
368
|
return ids[0];
|
|
366
369
|
};
|
|
367
370
|
}
|