@stage5/lumine 0.2.66 → 0.2.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -1
- package/lib/admin-featured.js +559 -0
- package/lib/admin-runtime-logs.js +786 -0
- package/lib/admin-workflows.js +121 -46
- package/lib/admin.js +474 -40
- package/lib/commands.js +52 -8
- package/lib/sponsor-duty.js +479 -28
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +2 -2
- package/sdk/LUMINE_ADMIN.md +515 -83
package/lib/sponsor-duty.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
4
|
import fs from "node:fs/promises";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
@@ -10,6 +11,10 @@ import { requestJson } from "./http.js";
|
|
|
10
11
|
import { sleep } from "./util.js";
|
|
11
12
|
import { collectProjectFiles } from "./workspace.js";
|
|
12
13
|
|
|
14
|
+
const ACTIVE_JOB_STATUSES = new Set(["leased", "working", "waiting_user"]);
|
|
15
|
+
// Colliding duty commands wait this long for the state lock before failing.
|
|
16
|
+
const SPONSOR_LOCK_WAIT_MS = 15_000;
|
|
17
|
+
|
|
13
18
|
const SPONSOR_PATH = "/cli/sponsor";
|
|
14
19
|
const EXECUTION_MODE = "agent_session_v2";
|
|
15
20
|
const STATE_VERSION = 2;
|
|
@@ -61,7 +66,8 @@ export async function sponsorDutyCommand(options, args, commandServices) {
|
|
|
61
66
|
return;
|
|
62
67
|
}
|
|
63
68
|
if (action !== "start") throw new Error(sponsorDutyUsage());
|
|
64
|
-
|
|
69
|
+
const startOptions = { ...options, sponsorStateForStart: true };
|
|
70
|
+
await withSponsorStateLock(startOptions, () => startDuty(startOptions));
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
export async function sponsorJobCommand(options, args, commandServices) {
|
|
@@ -112,6 +118,43 @@ export async function sponsorJobCommand(options, args, commandServices) {
|
|
|
112
118
|
);
|
|
113
119
|
return;
|
|
114
120
|
}
|
|
121
|
+
if (action === "sync-main") {
|
|
122
|
+
assertNoExtraArgs(args, 2);
|
|
123
|
+
await withSponsorStateLock(options, () =>
|
|
124
|
+
syncJobFromMain(options, jobId, commandServices),
|
|
125
|
+
);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (action === "assets") {
|
|
129
|
+
await withSponsorStateLock(options, () =>
|
|
130
|
+
jobAssets(options, jobId, args.slice(2), commandServices),
|
|
131
|
+
);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (action === "hold") {
|
|
135
|
+
assertNoExtraArgs(args, 2);
|
|
136
|
+
await holdJobLease(options, jobId);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (action === "rename" || action === "describe") {
|
|
140
|
+
await withSponsorStateLock(options, () =>
|
|
141
|
+
jobDetails(options, jobId, action, args.slice(2).join(" "), commandServices),
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (action === "suggest") {
|
|
146
|
+
assertNoExtraArgs(args, 3);
|
|
147
|
+
await withSponsorStateLock(options, () =>
|
|
148
|
+
jobSuggest(options, jobId, String(args[2] || ""), commandServices),
|
|
149
|
+
);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (action === "thumbnail") {
|
|
153
|
+
await withSponsorStateLock(options, () =>
|
|
154
|
+
jobThumbnail(options, jobId, args.slice(2), commandServices),
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
115
158
|
if (action === "complete") {
|
|
116
159
|
assertNoExtraArgs(args, 2);
|
|
117
160
|
await withSponsorStateLock(options, () =>
|
|
@@ -215,7 +258,7 @@ async function startDuty(options) {
|
|
|
215
258
|
`Shared Zero/Ciel sponsor duty #${dutyId} is open under this ${displayProvider(provider)} session.`,
|
|
216
259
|
`Declared runtime: model=${requestedModel}, effort=${requestedEffort}, service-tier=${options.sponsorServiceTier || "provider default"}.`,
|
|
217
260
|
"This same live agent session must keep checking in, receive approved plans, and perform the work itself.",
|
|
218
|
-
"
|
|
261
|
+
"The CLI keeps a lease keeper for each job you begin in this session; never hand jobs to a newly spawned provider process.",
|
|
219
262
|
"Run `lumine sponsor duty watch` repeatedly to stay present and receive an assignment.",
|
|
220
263
|
],
|
|
221
264
|
);
|
|
@@ -543,12 +586,6 @@ async function changeDutyState(options, action) {
|
|
|
543
586
|
}
|
|
544
587
|
throw new Error("No active sponsor duty session was found.");
|
|
545
588
|
}
|
|
546
|
-
if (duties.length > 1) {
|
|
547
|
-
throw new Error(
|
|
548
|
-
`Multiple legacy duty sessions remain open (${duties.map((duty) => `#${duty.id}`).join(", ")}). Stop them before using shared duty.`,
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
|
-
const dutyId = Number(duties[0].id || 0);
|
|
552
589
|
const localRecord =
|
|
553
590
|
action === "stop"
|
|
554
591
|
? await readSponsorStateForStop(options)
|
|
@@ -558,6 +595,20 @@ async function changeDutyState(options, action) {
|
|
|
558
595
|
invalidState: null,
|
|
559
596
|
};
|
|
560
597
|
let localState = localRecord.state;
|
|
598
|
+
// A sponsor may keep several live sessions on duty (a pool). This session
|
|
599
|
+
// acts on the duty its own local record names; only without a local
|
|
600
|
+
// record is more than one open duty ambiguous.
|
|
601
|
+
const localDutyId = Number(localState?.duty?.id || 0);
|
|
602
|
+
const ownDuty =
|
|
603
|
+
localDutyId > 0
|
|
604
|
+
? duties.find((duty) => Number(duty.id) === localDutyId)
|
|
605
|
+
: null;
|
|
606
|
+
if (!ownDuty && duties.length > 1) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`Multiple duty sessions remain open (${duties.map((duty) => `#${duty.id}`).join(", ")}) and this session owns none of them. Run this from the session that started the duty you want to change.`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
const dutyId = Number((ownDuty || duties[0]).id || 0);
|
|
561
612
|
if (localState) assertSponsorStateAccount(localState, auth);
|
|
562
613
|
const preservesAnotherAccountState =
|
|
563
614
|
sponsorStateBelongsToAnotherAccount(localRecord.invalidState, auth);
|
|
@@ -668,17 +719,20 @@ async function beginJob(options, jobId) {
|
|
|
668
719
|
}
|
|
669
720
|
const agent = await ensureCoordinator({ options, auth, state, jobState });
|
|
670
721
|
jobState.coordinator = agent;
|
|
722
|
+
if (!options.sponsorNoLeaseKeeper) startJobLeaseKeeper({ options, state, jobState });
|
|
671
723
|
await writeSponsorState(options, state);
|
|
672
724
|
({ state } = await heartbeatJob({ options, auth, state, jobId }));
|
|
673
725
|
const canonicalJobState = requireJobState(state, jobId);
|
|
674
726
|
const consultation = isConsultationJob(jobState);
|
|
675
|
-
printJsonOrLines(options, { job: canonicalJobState.job, coordinator: agent }, [
|
|
727
|
+
printJsonOrLines(options, { job: canonicalJobState.job, coordinator: agent, leaseKeeperPid: canonicalJobState.keeperPid || null }, [
|
|
676
728
|
`Workshop job #${jobId} is now being handled by this same on-duty agent session.`,
|
|
677
729
|
consultation
|
|
678
730
|
? `Inspect the approved project in ${jobState.workspaceDir} without editing it, then answer the approved question.`
|
|
679
731
|
: `Apply the approved plan in ${jobState.workspaceDir}.`,
|
|
680
732
|
`Introduce yourself to ${displayPersona(jobState.job.persona)} as Lumine with: lumine sponsor job update ${jobId} --file <message-file> --phase starting`,
|
|
681
|
-
|
|
733
|
+
canonicalJobState.keeperPid
|
|
734
|
+
? `A lease keeper (pid ${canonicalJobState.keeperPid}) checks in for this session while you work; still run lumine sponsor job pulse ${jobId} between steps to read approved follow-ups.`
|
|
735
|
+
: `No lease keeper runs for this session; run lumine sponsor job pulse ${jobId} at least every few minutes and between steps.`,
|
|
682
736
|
]);
|
|
683
737
|
}
|
|
684
738
|
|
|
@@ -693,6 +747,10 @@ async function publishDialogueUpdate(options, jobId) {
|
|
|
693
747
|
if (phase.length > 40) {
|
|
694
748
|
throw new Error("--phase must be at most 40 characters.");
|
|
695
749
|
}
|
|
750
|
+
const kind = String(options.sponsorUpdateKind || "progress").trim().toLowerCase();
|
|
751
|
+
if (kind !== "progress" && kind !== "question") {
|
|
752
|
+
throw new Error("--kind must be progress (default) or question.");
|
|
753
|
+
}
|
|
696
754
|
const messagePath = path.resolve(messageFile);
|
|
697
755
|
const messageStat = await fs.stat(messagePath);
|
|
698
756
|
if (!messageStat.isFile() || messageStat.size > 8_000) {
|
|
@@ -721,6 +779,7 @@ async function publishDialogueUpdate(options, jobId) {
|
|
|
721
779
|
clientUpdateKey,
|
|
722
780
|
phase: phase || null,
|
|
723
781
|
message,
|
|
782
|
+
kind,
|
|
724
783
|
},
|
|
725
784
|
}),
|
|
726
785
|
options.signal,
|
|
@@ -729,11 +788,44 @@ async function publishDialogueUpdate(options, jobId) {
|
|
|
729
788
|
throw new Error("Twinkle did not confirm the canonical Lumine update.");
|
|
730
789
|
}
|
|
731
790
|
printJsonOrLines(options, result, [
|
|
732
|
-
`Lumine → ${displayPersona(jobState.job.persona)}:`,
|
|
791
|
+
`Lumine → ${displayPersona(jobState.job.persona)}${kind === "question" ? " (question for the user)" : ""}:`,
|
|
733
792
|
result.update.message,
|
|
793
|
+
...(kind === "question"
|
|
794
|
+
? [`The answer arrives as an approved follow-up relay: keep running lumine sponsor job pulse ${jobId} and read the refreshed assignment.`]
|
|
795
|
+
: []),
|
|
796
|
+
]);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Title and description of the approved workspace, under the job identity.
|
|
800
|
+
async function jobDetails(options, jobId, field, text, commandServices) {
|
|
801
|
+
const value = String(text || "").trim();
|
|
802
|
+
if (!value) throw new Error(`Usage: lumine sponsor job ${field} <job-id> <text>`);
|
|
803
|
+
const auth = await ensureSponsorAuth(options);
|
|
804
|
+
let state = await loadOwnedState({ options, auth });
|
|
805
|
+
({ state } = await heartbeatJob({ options, auth, state, jobId }));
|
|
806
|
+
const jobState = requireJobState(state, jobId);
|
|
807
|
+
if (isConsultationJob(jobState)) {
|
|
808
|
+
throw new Error("A read-only consultation cannot change project details.");
|
|
809
|
+
}
|
|
810
|
+
if (jobState.job?.targetBuild?.kind !== "main") {
|
|
811
|
+
throw new Error(
|
|
812
|
+
"Title and description belong to the project's Main; a branch keeps the original details.",
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
const fn = field === "rename" ? commandServices?.renameBuild : commandServices?.describeBuild;
|
|
816
|
+
if (typeof fn !== "function") throw new Error("This Lumine CLI build cannot update job project details.");
|
|
817
|
+
const jobOptions = jobWorkspaceOptions(options, state, jobState, {
|
|
818
|
+
json: false,
|
|
819
|
+
positional: [],
|
|
820
|
+
...(field === "rename" ? { title: value } : { description: value, descriptionProvided: true, noDescription: false }),
|
|
821
|
+
});
|
|
822
|
+
const output = await runJobScopedCommand(options, jobOptions, (o) => fn(o));
|
|
823
|
+
printJsonOrLines(options, { job: jobState.job, [field]: value, output }, [
|
|
824
|
+
field === "rename" ? `Workshop job #${jobId}: project renamed to "${value}".` : `Workshop job #${jobId}: project description updated.`,
|
|
734
825
|
]);
|
|
735
826
|
}
|
|
736
827
|
|
|
828
|
+
|
|
737
829
|
async function startHelper(options, jobId) {
|
|
738
830
|
const auth = await ensureSponsorAuth(options);
|
|
739
831
|
let state = await loadOwnedState({ options, auth });
|
|
@@ -840,6 +932,269 @@ async function markRelaysApplied(options, jobId, relayIds) {
|
|
|
840
932
|
]);
|
|
841
933
|
}
|
|
842
934
|
|
|
935
|
+
// Runs the branch's Main sync under the job identity so the server can
|
|
936
|
+
// advance the job's restore point to the merged snapshot. Only editing jobs
|
|
937
|
+
// on a requester-owned branch can sync.
|
|
938
|
+
async function syncJobFromMain(options, jobId, commandServices) {
|
|
939
|
+
const auth = await ensureSponsorAuth(options);
|
|
940
|
+
let state = await loadOwnedState({ options, auth });
|
|
941
|
+
({ state } = await heartbeatJob({ options, auth, state, jobId }));
|
|
942
|
+
const jobState = requireJobState(state, jobId);
|
|
943
|
+
if (isConsultationJob(jobState)) {
|
|
944
|
+
throw new Error("A read-only consultation never changes the branch.");
|
|
945
|
+
}
|
|
946
|
+
if (jobState.job?.targetBuild?.kind !== "branch") {
|
|
947
|
+
throw new Error(
|
|
948
|
+
"Only a requester-owned branch can be updated from Main; this job targets Main itself.",
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
if (typeof commandServices?.updateFromMain !== "function") {
|
|
952
|
+
throw new Error("This Lumine CLI build cannot sync a job branch from Main.");
|
|
953
|
+
}
|
|
954
|
+
const jobOptions = jobWorkspaceOptions(options, state, jobState, {
|
|
955
|
+
json: false,
|
|
956
|
+
quiet: Boolean(options.quiet),
|
|
957
|
+
});
|
|
958
|
+
// The shared update-from-main printer writes plain lines; keep --json
|
|
959
|
+
// output pure by collecting them into the result instead.
|
|
960
|
+
const syncLines = [];
|
|
961
|
+
const originalLog = console.log;
|
|
962
|
+
if (options.json) console.log = (...parts) => syncLines.push(parts.join(" "));
|
|
963
|
+
try {
|
|
964
|
+
await commandServices.updateFromMain(jobOptions);
|
|
965
|
+
} finally {
|
|
966
|
+
console.log = originalLog;
|
|
967
|
+
}
|
|
968
|
+
({ state } = await heartbeatJob({ options, auth, state, jobId }));
|
|
969
|
+
const refreshed = requireJobState(state, jobId);
|
|
970
|
+
refreshed.initialFileHashes = hashProjectFiles(
|
|
971
|
+
await collectProjectFiles(refreshed.workspaceDir),
|
|
972
|
+
);
|
|
973
|
+
await writeAssignment(refreshed, state);
|
|
974
|
+
await writeSponsorState(options, state);
|
|
975
|
+
printJsonOrLines(options, { job: refreshed.job, sync: syncLines }, [
|
|
976
|
+
`Workshop job #${jobId}: branch synced from Main under the job identity.`,
|
|
977
|
+
`Restore point is now ${refreshed.job?.restorePoint ? `artifact version #${refreshed.job.restorePoint.versionNumber || refreshed.job.restorePoint.artifactVersionId}` : "unchanged"}.`,
|
|
978
|
+
]);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Uploads generated media into the approved workspace's asset space under
|
|
982
|
+
// the job identity (the requester's own uploads for that build).
|
|
983
|
+
async function jobAssets(options, jobId, args, commandServices) {
|
|
984
|
+
const sub = String(args[0] || "").trim().toLowerCase();
|
|
985
|
+
const files = args.slice(1);
|
|
986
|
+
if (sub !== "upload" || files.length === 0) {
|
|
987
|
+
throw new Error(
|
|
988
|
+
`Usage: lumine sponsor job assets ${jobId} upload <file...>`,
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
const auth = await ensureSponsorAuth(options);
|
|
992
|
+
let state = await loadOwnedState({ options, auth });
|
|
993
|
+
({ state } = await heartbeatJob({ options, auth, state, jobId }));
|
|
994
|
+
const jobState = requireJobState(state, jobId);
|
|
995
|
+
if (isConsultationJob(jobState)) {
|
|
996
|
+
throw new Error("A read-only consultation cannot add assets.");
|
|
997
|
+
}
|
|
998
|
+
if (typeof commandServices?.uploadAssets !== "function") {
|
|
999
|
+
throw new Error("This Lumine CLI build cannot upload job assets.");
|
|
1000
|
+
}
|
|
1001
|
+
const jobOptions = jobWorkspaceOptions(options, state, jobState, {
|
|
1002
|
+
json: Boolean(options.json),
|
|
1003
|
+
quiet: false,
|
|
1004
|
+
positional: ["upload", ...files],
|
|
1005
|
+
skipAssetManifest: false,
|
|
1006
|
+
});
|
|
1007
|
+
await commandServices.uploadAssets(jobOptions);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Runs a shared CLI command under the job identity with its plain output
|
|
1011
|
+
// collected, so a --json caller still gets pure JSON.
|
|
1012
|
+
async function runJobScopedCommand(options, jobOptions, fn) {
|
|
1013
|
+
const lines = [];
|
|
1014
|
+
const originalLog = console.log;
|
|
1015
|
+
if (options.json) console.log = (...parts) => lines.push(parts.join(" "));
|
|
1016
|
+
try {
|
|
1017
|
+
await fn(jobOptions);
|
|
1018
|
+
} finally {
|
|
1019
|
+
console.log = originalLog;
|
|
1020
|
+
}
|
|
1021
|
+
return lines;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Hands the approved branch to the project owner (branch suggestion with a
|
|
1025
|
+
// note Lumine wrote) or suggests the branch's thumbnail, under the job
|
|
1026
|
+
// identity. Only editing jobs on a requester-owned branch can suggest.
|
|
1027
|
+
async function jobSuggest(options, jobId, kind, commandServices) {
|
|
1028
|
+
const suggestionAction = String(kind || "").trim().toLowerCase();
|
|
1029
|
+
if (suggestionAction !== "branch" && suggestionAction !== "thumbnail") {
|
|
1030
|
+
throw new Error(
|
|
1031
|
+
`Usage: lumine sponsor job suggest ${jobId} branch --note <message> | lumine sponsor job suggest ${jobId} thumbnail`,
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
const note = String(options.note || "").trim();
|
|
1035
|
+
if (suggestionAction === "branch" && !note) {
|
|
1036
|
+
throw new Error(
|
|
1037
|
+
"Write the note for the project owner with --note <message>; Lumine composes it from the actual work, never from dictation.",
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
const auth = await ensureSponsorAuth(options);
|
|
1041
|
+
let state = await loadOwnedState({ options, auth });
|
|
1042
|
+
({ state } = await heartbeatJob({ options, auth, state, jobId }));
|
|
1043
|
+
const jobState = requireJobState(state, jobId);
|
|
1044
|
+
if (isConsultationJob(jobState)) {
|
|
1045
|
+
throw new Error("A read-only consultation cannot suggest anything.");
|
|
1046
|
+
}
|
|
1047
|
+
if (jobState.job?.targetBuild?.kind !== "branch") {
|
|
1048
|
+
throw new Error(
|
|
1049
|
+
"Suggestions go from a requester-owned branch to the project owner; this job targets Main itself.",
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
const send =
|
|
1053
|
+
suggestionAction === "branch"
|
|
1054
|
+
? commandServices?.notifyOwner
|
|
1055
|
+
: commandServices?.suggestThumbnail;
|
|
1056
|
+
if (typeof send !== "function") {
|
|
1057
|
+
throw new Error("This Lumine CLI build cannot send job suggestions.");
|
|
1058
|
+
}
|
|
1059
|
+
const jobOptions = jobWorkspaceOptions(options, state, jobState, { json: false });
|
|
1060
|
+
const jobAuth = await resolveAuth(jobOptions);
|
|
1061
|
+
const result = await send({
|
|
1062
|
+
options: jobOptions,
|
|
1063
|
+
auth: jobAuth,
|
|
1064
|
+
rootBuildId: Number(jobState.job.rootBuild.id),
|
|
1065
|
+
contributionBuildId: Number(jobState.job.targetBuild.id),
|
|
1066
|
+
...(suggestionAction === "branch" ? { note } : {}),
|
|
1067
|
+
});
|
|
1068
|
+
const messageId = Number(result?.message?.id || 0);
|
|
1069
|
+
// The hand-off message is this job's proof of work when no file changed.
|
|
1070
|
+
if (messageId > 0) {
|
|
1071
|
+
jobState.handoffMessageIds = Array.from(
|
|
1072
|
+
new Set([...(jobState.handoffMessageIds || []), messageId]),
|
|
1073
|
+
);
|
|
1074
|
+
await writeSponsorState(options, state);
|
|
1075
|
+
}
|
|
1076
|
+
printJsonOrLines(
|
|
1077
|
+
options,
|
|
1078
|
+
{ job: jobState.job, suggestion: suggestionAction, messageId: messageId || null },
|
|
1079
|
+
[
|
|
1080
|
+
suggestionAction === "branch"
|
|
1081
|
+
? `Workshop job #${jobId}: branch suggested to the project owner with Lumine's note.`
|
|
1082
|
+
: `Workshop job #${jobId}: thumbnail suggested to the project owner.`,
|
|
1083
|
+
],
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Sets the approved branch's thumbnail from a local image under the job
|
|
1088
|
+
// identity (upload + metadata commit). Pair with `suggest <id> thumbnail`.
|
|
1089
|
+
async function jobThumbnail(options, jobId, args, commandServices) {
|
|
1090
|
+
const sub = String(args[0] || "").trim().toLowerCase();
|
|
1091
|
+
const file = String(args[1] || "").trim();
|
|
1092
|
+
if (sub !== "set" || !file || args.length > 2) {
|
|
1093
|
+
throw new Error(`Usage: lumine sponsor job thumbnail ${jobId} set <file>`);
|
|
1094
|
+
}
|
|
1095
|
+
const auth = await ensureSponsorAuth(options);
|
|
1096
|
+
let state = await loadOwnedState({ options, auth });
|
|
1097
|
+
({ state } = await heartbeatJob({ options, auth, state, jobId }));
|
|
1098
|
+
const jobState = requireJobState(state, jobId);
|
|
1099
|
+
if (isConsultationJob(jobState)) {
|
|
1100
|
+
throw new Error("A read-only consultation cannot change the thumbnail.");
|
|
1101
|
+
}
|
|
1102
|
+
if (typeof commandServices?.thumbnailCommand !== "function") {
|
|
1103
|
+
throw new Error("This Lumine CLI build cannot set job thumbnails.");
|
|
1104
|
+
}
|
|
1105
|
+
const jobOptions = jobWorkspaceOptions(options, state, jobState, {
|
|
1106
|
+
json: false,
|
|
1107
|
+
yes: true,
|
|
1108
|
+
positional: ["set", file],
|
|
1109
|
+
});
|
|
1110
|
+
const output = await runJobScopedCommand(options, jobOptions, (o) =>
|
|
1111
|
+
commandServices.thumbnailCommand(o),
|
|
1112
|
+
);
|
|
1113
|
+
printJsonOrLines(options, { job: jobState.job, output }, [
|
|
1114
|
+
`Workshop job #${jobId}: thumbnail set on the approved branch.`,
|
|
1115
|
+
]);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Foreground lease keeper: pulses the job while the owning session works.
|
|
1119
|
+
// `job begin` starts one detached child per job; complete/fail stop it.
|
|
1120
|
+
async function holdJobLease(options, jobId) {
|
|
1121
|
+
const every = Math.max(15, Number(options.sponsorHoldEverySeconds || 40));
|
|
1122
|
+
let consecutiveFailures = 0;
|
|
1123
|
+
for (;;) {
|
|
1124
|
+
let keepGoing = true;
|
|
1125
|
+
try {
|
|
1126
|
+
keepGoing = await withSponsorStateLock(options, async () => {
|
|
1127
|
+
const auth = await ensureSponsorAuth(options);
|
|
1128
|
+
const state = await loadOwnedState({ options, auth });
|
|
1129
|
+
if (!state.jobs?.[String(jobId)]) return false;
|
|
1130
|
+
const { state: next } = await heartbeatJob({
|
|
1131
|
+
options,
|
|
1132
|
+
auth,
|
|
1133
|
+
state,
|
|
1134
|
+
jobId,
|
|
1135
|
+
});
|
|
1136
|
+
const jobState = next.jobs?.[String(jobId)];
|
|
1137
|
+
return Boolean(
|
|
1138
|
+
jobState && ACTIVE_JOB_STATUSES.has(String(jobState.job?.status)),
|
|
1139
|
+
);
|
|
1140
|
+
});
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
const message = String(error?.message || error);
|
|
1143
|
+
// Lease or duty gone: nothing left to hold.
|
|
1144
|
+
if (/no longer active|lease|No local agent-owned duty|does not exist/i.test(message)) {
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
consecutiveFailures += 1;
|
|
1148
|
+
console.error(`lumine: lease keeper check-in failed: ${message}`);
|
|
1149
|
+
// The owning session's own commands re-establish the lease; a keeper
|
|
1150
|
+
// that cannot reach Twinkle three times in a row must not linger.
|
|
1151
|
+
if (consecutiveFailures >= 3) return;
|
|
1152
|
+
}
|
|
1153
|
+
if (!keepGoing) return;
|
|
1154
|
+
consecutiveFailures = 0;
|
|
1155
|
+
await new Promise((resolve) => setTimeout(resolve, every * 1_000));
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function startJobLeaseKeeper({ options, state, jobState }) {
|
|
1160
|
+
if (jobState.keeperPid) return;
|
|
1161
|
+
// A detached child is re-parented once this command exits, so a session
|
|
1162
|
+
// identified only by process ancestry could not prove it is the same live
|
|
1163
|
+
// session. Those sessions keep the lease by pulsing between steps instead.
|
|
1164
|
+
if (state?.operatorSession?.bindingEvidence !== "runtime_session_id") {
|
|
1165
|
+
jobState.keeperSkippedReason = "agent_process_ancestry";
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
const args = [
|
|
1169
|
+
process.argv[1],
|
|
1170
|
+
"sponsor",
|
|
1171
|
+
"job",
|
|
1172
|
+
"hold",
|
|
1173
|
+
String(jobState.job.id),
|
|
1174
|
+
"--auth-file",
|
|
1175
|
+
String(options.authFile),
|
|
1176
|
+
"--api-url",
|
|
1177
|
+
String(options.apiUrl),
|
|
1178
|
+
];
|
|
1179
|
+
const child = spawn(process.execPath, args, {
|
|
1180
|
+
detached: true,
|
|
1181
|
+
stdio: "ignore",
|
|
1182
|
+
env: process.env,
|
|
1183
|
+
});
|
|
1184
|
+
child.unref();
|
|
1185
|
+
jobState.keeperPid = child.pid;
|
|
1186
|
+
jobState.keeperStartedAt = new Date().toISOString();
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function stopJobLeaseKeeper(jobState) {
|
|
1190
|
+
const pid = Number(jobState?.keeperPid || 0);
|
|
1191
|
+
if (!pid) return;
|
|
1192
|
+
try {
|
|
1193
|
+
process.kill(pid, "SIGTERM");
|
|
1194
|
+
} catch (_) {}
|
|
1195
|
+
jobState.keeperPid = null;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
843
1198
|
async function completeJob(options, jobId, commandServices) {
|
|
844
1199
|
const summary = String(options.summary || "").trim();
|
|
845
1200
|
if (!summary) {
|
|
@@ -929,6 +1284,48 @@ async function completeJob(options, jobId, commandServices) {
|
|
|
929
1284
|
]);
|
|
930
1285
|
return;
|
|
931
1286
|
}
|
|
1287
|
+
const handoffMessageIds = Array.isArray(jobState.handoffMessageIds)
|
|
1288
|
+
? jobState.handoffMessageIds.filter((id) => Number(id) > 0).map(Number)
|
|
1289
|
+
: [];
|
|
1290
|
+
if (changedPaths.length === 0 && handoffMessageIds.length > 0) {
|
|
1291
|
+
// Nothing to save: the job's outcome is the hand-off itself (a branch
|
|
1292
|
+
// or thumbnail suggestion sent to the owner under the job identity).
|
|
1293
|
+
const coordinatorResult = await completeAgent({
|
|
1294
|
+
options,
|
|
1295
|
+
auth,
|
|
1296
|
+
state,
|
|
1297
|
+
jobState,
|
|
1298
|
+
agent: jobState.coordinator,
|
|
1299
|
+
outcome: {
|
|
1300
|
+
finalText: summary.slice(0, 2000),
|
|
1301
|
+
changedPathCount: 0,
|
|
1302
|
+
changedPaths: [],
|
|
1303
|
+
agentSessionBound: true,
|
|
1304
|
+
handoffMessageIds,
|
|
1305
|
+
},
|
|
1306
|
+
});
|
|
1307
|
+
jobState.coordinator = { ...jobState.coordinator, ...coordinatorResult };
|
|
1308
|
+
await writeSponsorState(options, state);
|
|
1309
|
+
const result = await retrySponsorTransport(() =>
|
|
1310
|
+
sponsorJobRequest({
|
|
1311
|
+
options,
|
|
1312
|
+
auth,
|
|
1313
|
+
state,
|
|
1314
|
+
jobState,
|
|
1315
|
+
path: "/complete",
|
|
1316
|
+
body: {
|
|
1317
|
+
outcomeSummary: summary,
|
|
1318
|
+
reportedFilesHash: currentDigest,
|
|
1319
|
+
handoffMessageIds,
|
|
1320
|
+
},
|
|
1321
|
+
}),
|
|
1322
|
+
);
|
|
1323
|
+
await removeCompletedJob({ options, state, jobId });
|
|
1324
|
+
printJsonOrLines(options, result, [
|
|
1325
|
+
`Completed Workshop job #${jobId}; the approved branch was handed to the project owner and ${displayPersona(jobState.job.persona)} shared the result.`,
|
|
1326
|
+
]);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
932
1329
|
let savedArtifact = jobState.savedArtifact;
|
|
933
1330
|
const canonicalSavedArtifact = jobState.job?.savedArtifact;
|
|
934
1331
|
if (
|
|
@@ -989,6 +1386,7 @@ async function completeJob(options, jobId, commandServices) {
|
|
|
989
1386
|
artifactVersionId: savedArtifact.artifactVersionId,
|
|
990
1387
|
outcomeSummary: summary,
|
|
991
1388
|
reportedFilesHash: savedArtifact.filesHash,
|
|
1389
|
+
...(handoffMessageIds.length > 0 ? { handoffMessageIds } : {}),
|
|
992
1390
|
},
|
|
993
1391
|
}),
|
|
994
1392
|
);
|
|
@@ -1463,11 +1861,16 @@ ${jobState.forumContext ? `## Normal-access Build Forum snapshot\n\n${jobState.f
|
|
|
1463
1861
|
## Duty protocol
|
|
1464
1862
|
|
|
1465
1863
|
1. Run \`lumine sponsor job begin ${jobState.job.id}\` before ${consultation ? "inspecting the project" : "editing"}.
|
|
1466
|
-
2. Introduce yourself and publish meaningful milestones with \`lumine sponsor job update ${jobState.job.id} --file <message-file> --phase <name>\`. Speak as Lumine, never as the underlying provider.
|
|
1864
|
+
2. Introduce yourself and publish meaningful milestones with \`lumine sponsor job update ${jobState.job.id} --file <message-file> --phase <name>\`. Speak as Lumine, never as the underlying provider. To ask the user something you genuinely cannot decide yourself, add \`--kind question\`: ${displayPersona(jobState.job.persona)} puts it to the user and their answer comes back as an approved follow-up relay on your next pulse. Keep working on what does not depend on the answer.
|
|
1467
1865
|
3. ${consultation ? "Inspect only" : "Work only"} in the workspace above. Use your native same-session subagents only after registering each with \`helper-start\`. The coordinator alone runs sponsor CLI commands; helpers report their results back to it.
|
|
1468
|
-
4. Run \`lumine sponsor job pulse ${jobState.job.id}\` between substantial work steps to
|
|
1469
|
-
5. After applying a relay, record its exact ID with \`lumine sponsor job relay-applied ${jobState.job.id} <relay-id
|
|
1470
|
-
|
|
1866
|
+
4. Run \`lumine sponsor job pulse ${jobState.job.id}\` between substantial work steps to receive approved follow-ups. A CLI lease keeper for this same session checks in while you work, so multi-minute steps are fine; it stops when the job completes or fails.
|
|
1867
|
+
5. After applying a relay, record its exact ID with \`lumine sponsor job relay-applied ${jobState.job.id} <relay-id...>\`.${
|
|
1868
|
+
consultation
|
|
1869
|
+
? ""
|
|
1870
|
+
: `
|
|
1871
|
+
6. ${consultation ? "" : `For a new or renamed project, set its title and description with \`lumine sponsor job rename ${jobState.job.id} <title>\` and \`lumine sponsor job describe ${jobState.job.id} <text>\`, and its thumbnail with \`lumine sponsor job thumbnail ${jobState.job.id} set <file>\`. `}If the plan asks for the branch to be updated from Main, run \`lumine sponsor job sync-main ${jobState.job.id}\` FIRST, before editing anything (never your own login); it merges under the job identity and moves the restore point to the merged snapshot, so work done before the sync would be folded into the restore point and no longer count as this job's change. Generated images or audio go in with \`lumine sponsor job assets ${jobState.job.id} upload <file...>\` and are referenced by the printed URLs. When the plan asks you to hand the branch to the project owner, finish with \`lumine sponsor job suggest ${jobState.job.id} branch --note <message>\` using a note you wrote from the actual work (optionally \`lumine sponsor job thumbnail ${jobState.job.id} set <file>\` then \`lumine sponsor job suggest ${jobState.job.id} thumbnail\`); never ask the user for wording.`
|
|
1872
|
+
}
|
|
1873
|
+
${consultation ? "6" : "7"}. Finish with \`lumine sponsor job complete ${jobState.job.id} --summary "..."\`. ${consultation ? `For this consultation, the summary is the exact substantive answer ${displayPersona(jobState.job.persona)} will share; no artifact or project change is created.` : "The CLI will save a canonical artifact directly to the approved workspace. Publishing remains a separate owner action."}
|
|
1471
1874
|
`;
|
|
1472
1875
|
await fs.writeFile(jobState.assignmentPath, content, {
|
|
1473
1876
|
encoding: "utf8",
|
|
@@ -1537,7 +1940,7 @@ async function loadOwnedState({ options, auth, state = null }) {
|
|
|
1537
1940
|
return loaded;
|
|
1538
1941
|
}
|
|
1539
1942
|
|
|
1540
|
-
export function
|
|
1943
|
+
export function baseSponsorDutyStatePath(options) {
|
|
1541
1944
|
const authFile = path.resolve(options.authFile);
|
|
1542
1945
|
const contextHash = createHash("sha256")
|
|
1543
1946
|
.update(String(options.apiUrl || ""))
|
|
@@ -1551,6 +1954,36 @@ export function sponsorDutyStatePath(options) {
|
|
|
1551
1954
|
);
|
|
1552
1955
|
}
|
|
1553
1956
|
|
|
1957
|
+
// One sponsor login can keep several live agent sessions on duty at once.
|
|
1958
|
+
// The first session owns the base state file. When a further session on the
|
|
1959
|
+
// same login STARTS duty while the base belongs to another live session, it
|
|
1960
|
+
// gets its own file keyed by its session fingerprint; from then on that
|
|
1961
|
+
// session always finds its own file. Every other command still sees the base
|
|
1962
|
+
// record, so the account and same-session guards keep working.
|
|
1963
|
+
export function sponsorDutyStatePath(options) {
|
|
1964
|
+
const base = baseSponsorDutyStatePath(options);
|
|
1965
|
+
let session = null;
|
|
1966
|
+
try {
|
|
1967
|
+
session = detectSponsorAgentSession();
|
|
1968
|
+
} catch {
|
|
1969
|
+
return base;
|
|
1970
|
+
}
|
|
1971
|
+
const scoped = base.replace(
|
|
1972
|
+
/\.json$/,
|
|
1973
|
+
`-${session.fingerprintHash.slice(0, 12)}.json`,
|
|
1974
|
+
);
|
|
1975
|
+
if (existsSync(scoped)) return scoped;
|
|
1976
|
+
if (!options.sponsorStateForStart) return base;
|
|
1977
|
+
try {
|
|
1978
|
+
const state = JSON.parse(readFileSync(base, "utf8"));
|
|
1979
|
+
const owner = String(state?.operatorSession?.fingerprintHash || "");
|
|
1980
|
+
if (owner && owner !== session.fingerprintHash) return scoped;
|
|
1981
|
+
} catch {
|
|
1982
|
+
// No base file (or unreadable): this session takes the base path.
|
|
1983
|
+
}
|
|
1984
|
+
return base;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1554
1987
|
function assertSponsorStateAccount(state, auth) {
|
|
1555
1988
|
if (
|
|
1556
1989
|
auth.userId &&
|
|
@@ -1686,18 +2119,27 @@ async function withSponsorStateLock(options, operation) {
|
|
|
1686
2119
|
const lockDirectory = path.dirname(lockPath);
|
|
1687
2120
|
await fs.mkdir(lockDirectory, { recursive: true, mode: 0o700 });
|
|
1688
2121
|
let handle;
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
);
|
|
2122
|
+
// Short check-ins (the lease keeper, a pulse) hold the lock for well under
|
|
2123
|
+
// a second, so a colliding command waits briefly instead of failing.
|
|
2124
|
+
const waitUntil = Date.now() + SPONSOR_LOCK_WAIT_MS;
|
|
2125
|
+
for (;;) {
|
|
2126
|
+
try {
|
|
2127
|
+
handle = await fs.open(lockPath, "wx", 0o600);
|
|
2128
|
+
break;
|
|
2129
|
+
} catch (error) {
|
|
2130
|
+
if (error.code !== "EEXIST") throw error;
|
|
2131
|
+
const stale = await sponsorLockIsStale(lockPath);
|
|
2132
|
+
if (stale) {
|
|
2133
|
+
await fs.unlink(lockPath).catch(() => undefined);
|
|
2134
|
+
continue;
|
|
2135
|
+
}
|
|
2136
|
+
if (Date.now() >= waitUntil) {
|
|
2137
|
+
throw new Error(
|
|
2138
|
+
"Another sponsor duty command is still running for this login. Wait for it to finish instead of starting overlapping watchers or job mutations.",
|
|
2139
|
+
);
|
|
2140
|
+
}
|
|
2141
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
1698
2142
|
}
|
|
1699
|
-
await fs.unlink(lockPath);
|
|
1700
|
-
handle = await fs.open(lockPath, "wx", 0o600);
|
|
1701
2143
|
}
|
|
1702
2144
|
try {
|
|
1703
2145
|
await handle.writeFile(
|
|
@@ -1751,6 +2193,7 @@ async function archiveSponsorState(options, state) {
|
|
|
1751
2193
|
|
|
1752
2194
|
async function removeCompletedJob({ options, state, jobId }) {
|
|
1753
2195
|
const jobState = requireJobState(state, jobId);
|
|
2196
|
+
stopJobLeaseKeeper(jobState);
|
|
1754
2197
|
await scrubJobCredentials(jobState);
|
|
1755
2198
|
const nextJobs = { ...(state.jobs || {}) };
|
|
1756
2199
|
delete nextJobs[String(jobId)];
|
|
@@ -1765,6 +2208,7 @@ async function removeCompletedJob({ options, state, jobId }) {
|
|
|
1765
2208
|
|
|
1766
2209
|
async function preserveFailedJobWorkspace({ options, state, jobId, reason }) {
|
|
1767
2210
|
const jobState = requireJobState(state, jobId);
|
|
2211
|
+
stopJobLeaseKeeper(jobState);
|
|
1768
2212
|
await scrubJobCredentials(jobState);
|
|
1769
2213
|
const nextJobs = { ...(state.jobs || {}) };
|
|
1770
2214
|
delete nextJobs[String(jobId)];
|
|
@@ -2192,6 +2636,13 @@ function sponsorJobUsage() {
|
|
|
2192
2636
|
" lumine sponsor job relay-applied <job-id> <relay-id...>",
|
|
2193
2637
|
" lumine sponsor job helper-start <job-id> [--ordinal <n>]",
|
|
2194
2638
|
" lumine sponsor job helper-complete <job-id> --ordinal <n> --outcome <text> [--resolved-model <name>] [--resolved-effort <level>]",
|
|
2639
|
+
" lumine sponsor job sync-main <job-id>",
|
|
2640
|
+
" lumine sponsor job assets <job-id> upload <file...>",
|
|
2641
|
+
" lumine sponsor job hold <job-id>",
|
|
2642
|
+
" lumine sponsor job suggest <job-id> branch --note <message> | thumbnail",
|
|
2643
|
+
" lumine sponsor job thumbnail <job-id> set <file>",
|
|
2644
|
+
" lumine sponsor job rename <job-id> <title>",
|
|
2645
|
+
" lumine sponsor job describe <job-id> <text>",
|
|
2195
2646
|
" lumine sponsor job complete <job-id> --summary <text> [--resolved-model <name>] [--resolved-effort <level>] [--resolved-service-tier <tier>]",
|
|
2196
2647
|
" lumine sponsor job fail <job-id> --reason <text>",
|
|
2197
2648
|
].join("\n");
|