@remits/remits-cli 0.1.113 → 0.1.115
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 +3 -3
- package/index.js +933 -52
- package/package.json +3 -2
- package/skills/remits-cli/SKILL.md +163 -3549
- package/skills/remits-cli/references/account-targeting.md +174 -0
- package/skills/remits-cli/references/agent-sessions.md +222 -0
- package/skills/remits-cli/references/branch-variants.md +391 -0
- package/skills/remits-cli/references/cli-state.md +158 -0
- package/skills/remits-cli/references/command-reference.md +270 -0
- package/skills/remits-cli/references/component-integrity.md +180 -0
- package/skills/remits-cli/references/component-resolution.md +221 -0
- package/skills/remits-cli/references/development-loop.md +418 -0
- package/skills/remits-cli/references/investigation.md +254 -0
- package/skills/remits-cli/references/support-tickets.md +483 -0
- package/skills/remits-cli/references/tool-reference.md +962 -0
- package/skills/remits-cli/references/troubleshooting.md +135 -0
package/index.js
CHANGED
|
@@ -24,7 +24,10 @@ const SESSIONS_FILE = path.join(SESSION_DIR, 'sessions.json');
|
|
|
24
24
|
const CONFIG_FILE = path.join(SESSION_DIR, 'config.json');
|
|
25
25
|
const LISTENER_PID_FILE = path.join(SESSION_DIR, 'listener.pid');
|
|
26
26
|
const ACTIVITY_LOG_FILE = path.join(SESSION_DIR, 'activity.log');
|
|
27
|
-
const
|
|
27
|
+
const SKILL_SOURCE_DIR = path.join(__dirname, 'skills', 'remits-cli');
|
|
28
|
+
const SKILL_SOURCE_FILE = path.join(SKILL_SOURCE_DIR, 'SKILL.md');
|
|
29
|
+
const SKILL_REFERENCES_DIRNAME = 'references';
|
|
30
|
+
const SKILL_REFERENCES_SOURCE_DIR = path.join(SKILL_SOURCE_DIR, SKILL_REFERENCES_DIRNAME);
|
|
28
31
|
const ACCOUNT_REPO_INDEX_FILE = path.join(SESSION_DIR, 'account-repos.json');
|
|
29
32
|
const SERVICE_STATE_FILE = path.join(SESSION_DIR, 'service-state.json');
|
|
30
33
|
const ISSUES_DIR = path.join(SESSION_DIR, 'issues');
|
|
@@ -138,6 +141,10 @@ function hasExplicitDataModeFlag(flags) {
|
|
|
138
141
|
);
|
|
139
142
|
}
|
|
140
143
|
|
|
144
|
+
function dataModeFlagSource(flags) {
|
|
145
|
+
return hasExplicitDataModeFlag(flags) ? 'explicitFlag' : 'cliDefault';
|
|
146
|
+
}
|
|
147
|
+
|
|
141
148
|
function canonicalSessionKey(accountId, baseUrl, dataMode) {
|
|
142
149
|
const normalizedAccountId = Number(accountId);
|
|
143
150
|
const normalizedBaseUrl = normalizeBaseUrl(baseUrl || DEFAULT_BASE_URL);
|
|
@@ -344,6 +351,7 @@ function resolveSessionIdentity(cwd, flags = {}) {
|
|
|
344
351
|
// an account whose session is parked on prod cannot have a test run silently follow it. Report both
|
|
345
352
|
// rather than a single number that governs only some commands.
|
|
346
353
|
testRunDataMode: requestedDataMode || DEFAULT_DATA_MODE,
|
|
354
|
+
testRunDataModeSource: dataModeFlagSource(flags),
|
|
347
355
|
baseUrl: normalizeBaseUrl((session && session.baseUrl) || requestedBaseUrl || DEFAULT_BASE_URL),
|
|
348
356
|
updatedAt: session ? session.updatedAt : null,
|
|
349
357
|
sessionResolutionWarning
|
|
@@ -363,7 +371,8 @@ function printSessionIdentity(identity, options = {}) {
|
|
|
363
371
|
console.log('Branch:', identity.branchName || 'unresolved');
|
|
364
372
|
console.log('Workspace:', identity.workspace || 'none (shared default lane)');
|
|
365
373
|
console.log('Data mode:', (identity.dataMode || DEFAULT_DATA_MODE) + ' (applies to tool/tools/token)');
|
|
366
|
-
console.log('Data mode (test run):', identity.testRunDataMode || DEFAULT_DATA_MODE
|
|
374
|
+
console.log('Data mode (test run):', identity.testRunDataMode || DEFAULT_DATA_MODE,
|
|
375
|
+
'[' + (identity.testRunDataModeSource || 'cliDefault') + ']');
|
|
367
376
|
console.log('Base URL:', identity.baseUrl);
|
|
368
377
|
return;
|
|
369
378
|
}
|
|
@@ -393,6 +402,7 @@ function printSessionIdentity(identity, options = {}) {
|
|
|
393
402
|
// prod would otherwise read as "prod" here while the next test run went to the test lane. Say so rather
|
|
394
403
|
// than letting the tuple imply a lane it does not govern.
|
|
395
404
|
console.log(' Data mode (test run):', identity.testRunDataMode,
|
|
405
|
+
'[' + (identity.testRunDataModeSource || 'cliDefault') + ']',
|
|
396
406
|
identity.testRunDataMode === identity.dataMode ? '' : '— test run ignores the session lane; pass --data-mode prod to override');
|
|
397
407
|
console.log(' Base URL:', identity.baseUrl);
|
|
398
408
|
if (identity.updatedAt) {
|
|
@@ -764,14 +774,40 @@ function sha256(value) {
|
|
|
764
774
|
return crypto.createHash('sha256').update(value).digest('hex');
|
|
765
775
|
}
|
|
766
776
|
|
|
777
|
+
/**
|
|
778
|
+
* The branch this directory is on.
|
|
779
|
+
*
|
|
780
|
+
* <p><b>Git is asked FIRST, and the environment override is only a fallback.</b> It used to be the other
|
|
781
|
+
* way round, which made `REMITS_GIT_BRANCH` able to lie about a real branch: this value keys the component
|
|
782
|
+
* staging scope, the landing lease, the run context and every promotion command, so a stale export in a
|
|
783
|
+
* shell silently mislabelled every one of them — staging into a lane the tree is not on, and taking the
|
|
784
|
+
* landing lease for a branch nobody is landing. Nothing warned, because both halves looked correct.</p>
|
|
785
|
+
*
|
|
786
|
+
* <p>The override exists for the one case where git genuinely cannot answer: a DETACHED worktree, where
|
|
787
|
+
* `rev-parse --abbrev-ref HEAD` returns the literal `HEAD`. A ticket worker that detaches declares the
|
|
788
|
+
* branch it is working, and that declaration is used only where there is no real answer to contradict.
|
|
789
|
+
* So the legitimate use is preserved and the footgun is gone — on a normal checkout the variable now
|
|
790
|
+
* cannot change what anything reports.</p>
|
|
791
|
+
*/
|
|
767
792
|
function currentBranch(cwd) {
|
|
768
793
|
try {
|
|
769
|
-
|
|
794
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
795
|
+
// `HEAD` means detached: git has no branch name to give, so a declared one is the only answer.
|
|
796
|
+
if (branch && branch !== 'HEAD') return branch;
|
|
797
|
+
return remitsGitBranchOverride() || branch;
|
|
770
798
|
} catch (err) {
|
|
799
|
+
const override = remitsGitBranchOverride();
|
|
800
|
+
if (override) return override;
|
|
771
801
|
return 'main';
|
|
772
802
|
}
|
|
773
803
|
}
|
|
774
804
|
|
|
805
|
+
function remitsGitBranchOverride() {
|
|
806
|
+
const value = process.env.REMITS_GIT_BRANCH || process.env.REMITS_BRANCH_NAME;
|
|
807
|
+
const branch = value == null ? '' : String(value).trim();
|
|
808
|
+
return branch && branch !== 'HEAD' ? branch : null;
|
|
809
|
+
}
|
|
810
|
+
|
|
775
811
|
function shellQuote(value) {
|
|
776
812
|
return '\'' + String(value).replace(/'/g, '\'\"\'\"\'' ) + '\'';
|
|
777
813
|
}
|
|
@@ -1297,7 +1333,6 @@ function updateAccountRepoIndex(cwd) {
|
|
|
1297
1333
|
if (!info) {
|
|
1298
1334
|
return;
|
|
1299
1335
|
}
|
|
1300
|
-
try { syncInstalledSkills(); } catch (_) { /* best-effort */ }
|
|
1301
1336
|
const entry = buildAccountRepoEntryFromInfo(info, cwd, 'cwd');
|
|
1302
1337
|
if (!entry) {
|
|
1303
1338
|
return;
|
|
@@ -1371,7 +1406,6 @@ function discoverAccountRepos() {
|
|
|
1371
1406
|
for (const root of roots) {
|
|
1372
1407
|
walkForAccountInfo(root, 0, matches, visited, platformMatches);
|
|
1373
1408
|
}
|
|
1374
|
-
try { syncInstalledSkills(); } catch (_) { /* best-effort */ }
|
|
1375
1409
|
|
|
1376
1410
|
const entries = [];
|
|
1377
1411
|
for (const file of matches) {
|
|
@@ -1924,13 +1958,166 @@ async function loggedGet(api, cwd, endpoint, params = {}) {
|
|
|
1924
1958
|
}
|
|
1925
1959
|
}
|
|
1926
1960
|
|
|
1961
|
+
// ---------------------------------------------------------------------------
|
|
1962
|
+
// Skill delivery
|
|
1963
|
+
//
|
|
1964
|
+
// The skill is SKILL.md (the index and the rules) plus the reference files in
|
|
1965
|
+
// skills/remits-cli/references/. Both ship inside the npm package, so the whole
|
|
1966
|
+
// skill is available before the CLI has ever authenticated.
|
|
1967
|
+
//
|
|
1968
|
+
// Every delivered markdown file goes through deliverSkillMarkdown(), which is
|
|
1969
|
+
// the ONE delivery path: it substitutes the machine-specific placeholders and
|
|
1970
|
+
// resolves each file's table of contents to real line numbers. Writing a file
|
|
1971
|
+
// out with fs.writeFileSync + readFileSync instead would ship the same skill
|
|
1972
|
+
// with a different table of contents depending on which door it came out of.
|
|
1973
|
+
// ---------------------------------------------------------------------------
|
|
1974
|
+
|
|
1975
|
+
// GitHub-style heading anchor: lowercase, punctuation dropped, spaces to hyphens.
|
|
1976
|
+
function headingSlug(headingText) {
|
|
1977
|
+
return String(headingText || '')
|
|
1978
|
+
.toLowerCase()
|
|
1979
|
+
.replace(/[^a-z0-9 _-]/g, '')
|
|
1980
|
+
.trim()
|
|
1981
|
+
.replace(/\s/g, '-');
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
function formatTocEntry(indent, target) {
|
|
1985
|
+
if (!target) return null;
|
|
1986
|
+
return indent + '- L' + target.line + ' ' + target.title;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// Rewrites a document's "## Table of Contents" entries to carry the line number of
|
|
1990
|
+
// the heading they point at, turning the TOC into a jump table:
|
|
1991
|
+
//
|
|
1992
|
+
// - [Component Structure](#component-structure) -> - L59 Component Structure
|
|
1993
|
+
// - Line 31: Component Structure -> - L59 Component Structure
|
|
1994
|
+
//
|
|
1995
|
+
// Numbers are computed HERE, at delivery, never written into the repo file.
|
|
1996
|
+
// Hand-maintained line numbers rot on the first edit above them, and a WRONG
|
|
1997
|
+
// number is worse than none: it sends the reader to the wrong section, where it
|
|
1998
|
+
// may conclude the content is missing. Resolving at delivery means the numbers
|
|
1999
|
+
// cannot be stale, while the repo file keeps anchor links that survive edits and
|
|
2000
|
+
// produce clean diffs.
|
|
2001
|
+
//
|
|
2002
|
+
// INVARIANT: every rewrite is one line in, one line out. The line count must not
|
|
2003
|
+
// change, or the numbers computed against the input would no longer describe the
|
|
2004
|
+
// output. Locked down by test/skill-references.test.js.
|
|
2005
|
+
//
|
|
2006
|
+
// An entry whose target heading cannot be resolved is left exactly as written —
|
|
2007
|
+
// it degrades to the original text, never to a guessed number.
|
|
2008
|
+
function resolveTableOfContentsLineNumbers(markdown) {
|
|
2009
|
+
if (!markdown || !markdown.trim()) {
|
|
2010
|
+
return markdown;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
const lines = markdown.split('\n');
|
|
2014
|
+
const tocStart = lines.findIndex((line) => /^##\s+table of contents\s*$/i.test(line));
|
|
2015
|
+
if (tocStart < 0) {
|
|
2016
|
+
return markdown;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
let tocEnd = lines.length;
|
|
2020
|
+
for (let i = tocStart + 1; i < lines.length; i += 1) {
|
|
2021
|
+
if (/^##(?!#)\s+.*$/.test(lines[i])) {
|
|
2022
|
+
tocEnd = i;
|
|
2023
|
+
break;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// A `#`-prefixed line inside a fenced code block is CONTENT, not a heading. The
|
|
2028
|
+
// references routinely show markdown and shell samples that contain one. Indexing
|
|
2029
|
+
// those shifts GitHub's duplicate-slug disambiguation (-1, -2 ...) out of step with
|
|
2030
|
+
// the real headings, so a legitimate repeated heading resolves to the wrong line.
|
|
2031
|
+
const byAnchor = new Map();
|
|
2032
|
+
const byTitle = new Map();
|
|
2033
|
+
const slugCounts = new Map();
|
|
2034
|
+
let inFence = false;
|
|
2035
|
+
|
|
2036
|
+
lines.forEach((line, index) => {
|
|
2037
|
+
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
2038
|
+
inFence = !inFence;
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
if (inFence) return;
|
|
2042
|
+
|
|
2043
|
+
const heading = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
|
|
2044
|
+
if (!heading) return;
|
|
2045
|
+
|
|
2046
|
+
const title = heading[2].trim();
|
|
2047
|
+
const target = { line: index + 1, title };
|
|
2048
|
+
|
|
2049
|
+
const baseSlug = headingSlug(title);
|
|
2050
|
+
const seen = slugCounts.get(baseSlug) || 0;
|
|
2051
|
+
slugCounts.set(baseSlug, seen + 1);
|
|
2052
|
+
const slug = seen === 0 ? baseSlug : baseSlug + '-' + seen;
|
|
2053
|
+
|
|
2054
|
+
if (!byAnchor.has(slug)) byAnchor.set(slug, target);
|
|
2055
|
+
if (!byTitle.has(title)) byTitle.set(title, target);
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
let changed = false;
|
|
2059
|
+
for (let i = tocStart + 1; i < tocEnd; i += 1) {
|
|
2060
|
+
const rewritten = resolveTocEntry(lines[i], byAnchor, byTitle);
|
|
2061
|
+
if (rewritten !== null) {
|
|
2062
|
+
lines[i] = rewritten;
|
|
2063
|
+
changed = true;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
return changed ? lines.join('\n') : markdown;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// The rewritten entry carries the ACTUAL heading text, not the TOC's own link text.
|
|
2071
|
+
// The two drift (a heading gets reworded and the TOC entry does not), and emitting
|
|
2072
|
+
// the heading text both self-heals that drift and guarantees the entry is a literal
|
|
2073
|
+
// match for the heading — so an agent can grep it as-is rather than trusting the number.
|
|
2074
|
+
//
|
|
2075
|
+
// Returns null when the line is not a resolvable TOC entry and must be left untouched.
|
|
2076
|
+
function resolveTocEntry(line, byAnchor, byTitle) {
|
|
2077
|
+
const anchorEntry = /^(\s*)[-*]\s+\[(.+)\]\(#([^)\s]+)\)\s*$/.exec(line);
|
|
2078
|
+
if (anchorEntry) {
|
|
2079
|
+
return formatTocEntry(anchorEntry[1], byAnchor.get(anchorEntry[3]));
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// Legacy hand-written form. Re-resolving it corrects files that have not been
|
|
2083
|
+
// audited yet, instead of shipping their stale numbers.
|
|
2084
|
+
const legacyEntry = /^(\s*)[-*]\s+Line\s+\d+:\s*(.+?)\s*$/.exec(line);
|
|
2085
|
+
if (legacyEntry) {
|
|
2086
|
+
const title = legacyEntry[2];
|
|
2087
|
+
return formatTocEntry(legacyEntry[1], byTitle.get(title) || byAnchor.get(headingSlug(title)));
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
return null;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// THE single delivery path for a skill markdown file. Substitutes the placeholders
|
|
2094
|
+
// that only the installing machine can fill in, then resolves the table of contents.
|
|
2095
|
+
function deliverSkillMarkdown(sourceText, referencesDir) {
|
|
2096
|
+
let body = String(sourceText);
|
|
2097
|
+
body = body.split('{{ACCOUNT_REPO_INDEX_PATH}}').join(ACCOUNT_REPO_INDEX_FILE);
|
|
2098
|
+
body = body.split('{{SKILL_REFERENCES_DIR}}').join(referencesDir);
|
|
2099
|
+
return resolveTableOfContentsLineNumbers(body);
|
|
2100
|
+
}
|
|
2101
|
+
|
|
1927
2102
|
function resolveSkillTargets(target) {
|
|
1928
2103
|
const value = String(target || 'all').toLowerCase();
|
|
1929
2104
|
const home = os.homedir();
|
|
2105
|
+
// Gemini reads a FLAT skill file, so its references cannot sit beside it as
|
|
2106
|
+
// `references/`. That is why SKILL.md states the reference directory as an
|
|
2107
|
+
// absolute path substituted per target rather than a relative one.
|
|
1930
2108
|
const targets = {
|
|
1931
|
-
codex:
|
|
1932
|
-
|
|
1933
|
-
|
|
2109
|
+
codex: {
|
|
2110
|
+
skillPath: path.join(home, '.codex', 'skills', 'remits-cli', 'SKILL.md'),
|
|
2111
|
+
referencesDir: path.join(home, '.codex', 'skills', 'remits-cli', SKILL_REFERENCES_DIRNAME)
|
|
2112
|
+
},
|
|
2113
|
+
claude: {
|
|
2114
|
+
skillPath: path.join(home, '.claude', 'skills', 'remits-cli', 'SKILL.md'),
|
|
2115
|
+
referencesDir: path.join(home, '.claude', 'skills', 'remits-cli', SKILL_REFERENCES_DIRNAME)
|
|
2116
|
+
},
|
|
2117
|
+
gemini: {
|
|
2118
|
+
skillPath: path.join(home, '.gemini', 'skills', 'remits-cli.md'),
|
|
2119
|
+
referencesDir: path.join(home, '.gemini', 'skills', 'remits-cli', SKILL_REFERENCES_DIRNAME)
|
|
2120
|
+
}
|
|
1934
2121
|
};
|
|
1935
2122
|
|
|
1936
2123
|
if (value === 'all') {
|
|
@@ -1942,34 +2129,83 @@ function resolveSkillTargets(target) {
|
|
|
1942
2129
|
return [[value, targets[value]]];
|
|
1943
2130
|
}
|
|
1944
2131
|
|
|
1945
|
-
function
|
|
2132
|
+
function listSkillReferenceSources() {
|
|
2133
|
+
if (!fs.existsSync(SKILL_REFERENCES_SOURCE_DIR)) {
|
|
2134
|
+
return [];
|
|
2135
|
+
}
|
|
2136
|
+
return fs.readdirSync(SKILL_REFERENCES_SOURCE_DIR)
|
|
2137
|
+
.filter((name) => name.endsWith('.md'))
|
|
2138
|
+
.sort();
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
function buildSkillSourceBody(referencesDir) {
|
|
1946
2142
|
if (!fs.existsSync(SKILL_SOURCE_FILE)) {
|
|
1947
2143
|
throw new Error('Skill source file not found: ' + SKILL_SOURCE_FILE);
|
|
1948
2144
|
}
|
|
2145
|
+
return deliverSkillMarkdown(fs.readFileSync(SKILL_SOURCE_FILE, 'utf8'), referencesDir);
|
|
2146
|
+
}
|
|
1949
2147
|
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
2148
|
+
// Install the reference set beside (or, for gemini, alongside) the skill file, and
|
|
2149
|
+
// prune any reference this CLI version no longer ships. A stale reference is worse
|
|
2150
|
+
// than a missing one: SKILL.md would not name it, but an agent listing the directory
|
|
2151
|
+
// would still find and trust it.
|
|
2152
|
+
function writeSkillReferences(referencesDir, options = {}) {
|
|
2153
|
+
const names = listSkillReferenceSources();
|
|
2154
|
+
const written = [];
|
|
2155
|
+
if (!names.length) {
|
|
2156
|
+
return written;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
ensureDir(referencesDir);
|
|
2160
|
+
const expected = new Set(names);
|
|
2161
|
+
|
|
2162
|
+
for (const name of names) {
|
|
2163
|
+
const body = deliverSkillMarkdown(
|
|
2164
|
+
fs.readFileSync(path.join(SKILL_REFERENCES_SOURCE_DIR, name), 'utf8'),
|
|
2165
|
+
referencesDir
|
|
2166
|
+
);
|
|
2167
|
+
const targetPath = path.join(referencesDir, name);
|
|
2168
|
+
if (options.overwrite === false && fs.existsSync(targetPath)) {
|
|
2169
|
+
continue;
|
|
2170
|
+
}
|
|
2171
|
+
if (writeFileIfChanged(targetPath, body)) {
|
|
2172
|
+
written.push(targetPath);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
try {
|
|
2177
|
+
for (const existing of fs.readdirSync(referencesDir)) {
|
|
2178
|
+
if (existing.endsWith('.md') && !expected.has(existing)) {
|
|
2179
|
+
fs.unlinkSync(path.join(referencesDir, existing));
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
} catch (_) {
|
|
2183
|
+
// Best-effort pruning; a stale file never blocks delivery of the current set.
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
return written;
|
|
1953
2187
|
}
|
|
1954
2188
|
|
|
1955
2189
|
function syncInstalledSkills(target = 'all') {
|
|
1956
2190
|
const targets = resolveSkillTargets(target);
|
|
1957
|
-
const sourceBody = buildSkillSourceBody();
|
|
1958
2191
|
const summary = {
|
|
1959
2192
|
updated: [],
|
|
1960
2193
|
failed: []
|
|
1961
2194
|
};
|
|
1962
2195
|
|
|
1963
|
-
for (const [agent,
|
|
2196
|
+
for (const [agent, spec] of targets) {
|
|
1964
2197
|
try {
|
|
1965
|
-
ensureDir(path.dirname(
|
|
1966
|
-
if (writeFileIfChanged(
|
|
1967
|
-
summary.updated.push({ agent, path:
|
|
2198
|
+
ensureDir(path.dirname(spec.skillPath));
|
|
2199
|
+
if (writeFileIfChanged(spec.skillPath, buildSkillSourceBody(spec.referencesDir))) {
|
|
2200
|
+
summary.updated.push({ agent, path: spec.skillPath });
|
|
2201
|
+
}
|
|
2202
|
+
for (const referencePath of writeSkillReferences(spec.referencesDir)) {
|
|
2203
|
+
summary.updated.push({ agent, path: referencePath });
|
|
1968
2204
|
}
|
|
1969
2205
|
} catch (error) {
|
|
1970
2206
|
summary.failed.push({
|
|
1971
2207
|
agent,
|
|
1972
|
-
path:
|
|
2208
|
+
path: spec.skillPath,
|
|
1973
2209
|
reason: error && error.message ? error.message : String(error)
|
|
1974
2210
|
});
|
|
1975
2211
|
}
|
|
@@ -1985,19 +2221,24 @@ async function installSkillsCommand(flags) {
|
|
|
1985
2221
|
|
|
1986
2222
|
const overwrite = flags.overwrite === true || flags.overwrite === 'true';
|
|
1987
2223
|
const targets = resolveSkillTargets(flags.target || 'all');
|
|
1988
|
-
const sourceBody = buildSkillSourceBody();
|
|
1989
2224
|
|
|
1990
2225
|
const installed = [];
|
|
1991
2226
|
const skipped = [];
|
|
1992
2227
|
|
|
1993
|
-
for (const [agent,
|
|
1994
|
-
ensureDir(path.dirname(
|
|
1995
|
-
if (fs.existsSync(
|
|
1996
|
-
skipped.push({ agent, path:
|
|
1997
|
-
|
|
2228
|
+
for (const [agent, spec] of targets) {
|
|
2229
|
+
ensureDir(path.dirname(spec.skillPath));
|
|
2230
|
+
if (fs.existsSync(spec.skillPath) && !overwrite) {
|
|
2231
|
+
skipped.push({ agent, path: spec.skillPath, reason: 'already exists (use --overwrite true)' });
|
|
2232
|
+
} else {
|
|
2233
|
+
atomicWriteFile(spec.skillPath, buildSkillSourceBody(spec.referencesDir));
|
|
2234
|
+
installed.push({ agent, path: spec.skillPath });
|
|
2235
|
+
}
|
|
2236
|
+
// References are refreshed even when the skill file itself was left alone: an
|
|
2237
|
+
// agent reading a customized SKILL.md still needs the reference set it points at.
|
|
2238
|
+
const references = writeSkillReferences(spec.referencesDir);
|
|
2239
|
+
if (references.length) {
|
|
2240
|
+
installed.push({ agent, path: spec.referencesDir + ' (' + references.length + ' reference file(s))' });
|
|
1998
2241
|
}
|
|
1999
|
-
atomicWriteFile(targetPath, sourceBody);
|
|
2000
|
-
installed.push({ agent, path: targetPath });
|
|
2001
2242
|
}
|
|
2002
2243
|
|
|
2003
2244
|
console.log('Skill installation complete.');
|
|
@@ -2249,18 +2490,24 @@ async function pushComponentsCommand(flags) {
|
|
|
2249
2490
|
// or, worse, conclude staging was broken and go on reading trunk. Scaled by payload size, floored at
|
|
2250
2491
|
// the old default, and overridable.
|
|
2251
2492
|
const api = buildAxios(baseUrl, session.token, stageTimeoutMs(components, flags));
|
|
2252
|
-
const response = await
|
|
2493
|
+
const response = await stageOrRefuse(api, cwd, {
|
|
2253
2494
|
token: session.token,
|
|
2254
2495
|
accountId,
|
|
2255
2496
|
branchName,
|
|
2256
2497
|
workspace,
|
|
2257
2498
|
dataMode,
|
|
2258
2499
|
mode,
|
|
2500
|
+
// WHERE this stage is coming from. The platform derives a lightweight presence record from it, so an
|
|
2501
|
+
// interactive agent that never ran `agent register` is still visible to every headless worker in the
|
|
2502
|
+
// fleet — and so the edit-lease check below can tell "somebody else holds it" from "I hold it".
|
|
2503
|
+
...runContextPayload(flags),
|
|
2504
|
+
agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
|
|
2505
|
+
acknowledge: flags.acknowledge || undefined,
|
|
2259
2506
|
// A partial (--changed-only) stage must not trigger the server's reconcile pass: reconcile deletes
|
|
2260
2507
|
// every staged entry absent from the manifest, which for a partial manifest is nearly all of them.
|
|
2261
2508
|
replace: !changedOnly,
|
|
2262
2509
|
components
|
|
2263
|
-
})
|
|
2510
|
+
});
|
|
2264
2511
|
response.changedFromWorkingTree = changedFromWorkingTree || [];
|
|
2265
2512
|
response.changedFromWorkingTreeAvailable = changedFromWorkingTree !== null;
|
|
2266
2513
|
|
|
@@ -2274,9 +2521,51 @@ async function pushComponentsCommand(flags) {
|
|
|
2274
2521
|
printStagingLane(response.branchName || branchName, response.workspace || workspace, workspaceSource(cwd, flags));
|
|
2275
2522
|
console.log('Data mode:', response.dataMode || dataMode);
|
|
2276
2523
|
console.log('Mode:', response.mode || mode + (changedOnly ? ' (--changed-only)' : ''));
|
|
2524
|
+
printComponentPolicy(response);
|
|
2277
2525
|
printStageSummary(response, flags);
|
|
2278
2526
|
}
|
|
2279
2527
|
|
|
2528
|
+
/**
|
|
2529
|
+
* A policy refusal is an ANSWER, not a transport failure.
|
|
2530
|
+
*
|
|
2531
|
+
* The platform answers 409 with the sentence to act on ("another agent holds the edit lease on this
|
|
2532
|
+
* repository and you are staging into the SHARED lane…"). That sentence must reach the caller intact —
|
|
2533
|
+
* an axios stack trace teaches an agent nothing and it will retry the identical call.
|
|
2534
|
+
*/
|
|
2535
|
+
async function stageOrRefuse(api, cwd, payload) {
|
|
2536
|
+
try {
|
|
2537
|
+
return await loggedPost(api, cwd, '/cli/components', payload).then((r) => r.data);
|
|
2538
|
+
} catch (err) {
|
|
2539
|
+
const body = err && err.response && err.response.data;
|
|
2540
|
+
const status = err && err.response && err.response.status;
|
|
2541
|
+
if (status === 409 && body && body.message) {
|
|
2542
|
+
if (body.editLease) {
|
|
2543
|
+
console.error('Edit lease: ' + describeRunLocation(body.editLease));
|
|
2544
|
+
}
|
|
2545
|
+
const refusal = new Error(body.message);
|
|
2546
|
+
refusal.refused = true;
|
|
2547
|
+
throw refusal;
|
|
2548
|
+
}
|
|
2549
|
+
throw err;
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
/** What the account's rules said about this stage or commit — including "they could not be read". */
|
|
2554
|
+
function printComponentPolicy(response) {
|
|
2555
|
+
const policy = response && response.policy;
|
|
2556
|
+
const lease = response && response.editLease;
|
|
2557
|
+
if (lease) {
|
|
2558
|
+
console.log('Note: another agent holds the edit lease on this repository — ' +
|
|
2559
|
+
(describeRunLocation(lease) || 'location not recorded') + '.');
|
|
2560
|
+
}
|
|
2561
|
+
if (!policy) return;
|
|
2562
|
+
if (policy.error) {
|
|
2563
|
+
console.error('POLICY NOT ENFORCED: this account has component rules that could not be parsed: ' + policy.error);
|
|
2564
|
+
}
|
|
2565
|
+
(policy.warnings || []).forEach((warning) => console.log('Policy warning: ' + warning));
|
|
2566
|
+
(policy.overrides || []).forEach((id) => console.log('Policy override recorded for rule: ' + id));
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2280
2569
|
function shouldPrintFullComponentResponse(flags) {
|
|
2281
2570
|
return flagEnabled(flags.verbose);
|
|
2282
2571
|
}
|
|
@@ -2369,9 +2658,45 @@ function printStatusSummary(response, flags) {
|
|
|
2369
2658
|
console.log(' (* = this command\'s lane)');
|
|
2370
2659
|
}
|
|
2371
2660
|
|
|
2661
|
+
printAccountLanes(response);
|
|
2662
|
+
|
|
2372
2663
|
printComponentCommandResponse('Components staging', response, flags);
|
|
2373
2664
|
}
|
|
2374
2665
|
|
|
2666
|
+
// Every staging lane on the ACCOUNT, across branches.
|
|
2667
|
+
//
|
|
2668
|
+
// The block above only ever covered lanes on the SAME git branch, because that is all its key pattern
|
|
2669
|
+
// could reach. So an agent on trunk could not learn that a sibling had components staged on a variant
|
|
2670
|
+
// branch of the same account — and those lanes are not merely differently named, they resolve and commit
|
|
2671
|
+
// into different worlds. "Am I working alone" was only answerable within one branch, which is the
|
|
2672
|
+
// narrower half of the question.
|
|
2673
|
+
//
|
|
2674
|
+
// Marked "as of" deliberately: this comes from the lane index, not from the staging keys. The caller's own
|
|
2675
|
+
// lane is still reported authoritatively above.
|
|
2676
|
+
function printAccountLanes(response) {
|
|
2677
|
+
const lanes = Array.isArray(response.accountLanes) ? response.accountLanes : [];
|
|
2678
|
+
const others = lanes.filter((lane) => !lane.mine);
|
|
2679
|
+
if (!others.length) return;
|
|
2680
|
+
|
|
2681
|
+
console.log('');
|
|
2682
|
+
console.log('Other staging lanes on this ACCOUNT (as of the last stage/clear in each):');
|
|
2683
|
+
lanes.forEach((lane) => {
|
|
2684
|
+
// null onTrunk means the owner could not be resolved. Say "unknown" rather than guessing a world.
|
|
2685
|
+
const world = lane.onTrunk === true ? 'trunk'
|
|
2686
|
+
: lane.onTrunk === false ? 'variant branch'
|
|
2687
|
+
: 'branch world unknown';
|
|
2688
|
+
const ws = lane.workspace ? ' [ws:' + lane.workspace + ']' : '';
|
|
2689
|
+
const who = lane.userEmail ? ' ' + lane.userEmail : '';
|
|
2690
|
+
const ttl = lane.expiresInSeconds != null
|
|
2691
|
+
? ' expires in ~' + Math.round(lane.expiresInSeconds / 60) + 'm' : '';
|
|
2692
|
+
console.log(' ' + (lane.mine ? '* ' : ' ') + lane.branchName + ws
|
|
2693
|
+
+ ' (' + world + ') — ' + (lane.stagedCountAsOf || 0) + ' component(s)' + who + ttl);
|
|
2694
|
+
});
|
|
2695
|
+
console.log(' (* = this command\'s lane)');
|
|
2696
|
+
console.log(' A variant-branch lane layers over that branch\'s ComponentVariant overlays, and a commit');
|
|
2697
|
+
console.log(' from it writes overlays — never the trunk rows a trunk lane commits to.');
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2375
2700
|
// Make the working tree's meaning explicit. An agent may be standing in a TRUNK checkout or in a
|
|
2376
2701
|
// VARIANT branch's checkout, and the two differ on BOTH ends of the loop: what a run resolves, and
|
|
2377
2702
|
// what a commit writes. Inferring that from the branch name alone is exactly the kind of ambiguity
|
|
@@ -2415,8 +2740,8 @@ function printClearSummary(response, flags) {
|
|
|
2415
2740
|
* `remits-cli workspace [show|use <name>|clear]`
|
|
2416
2741
|
*
|
|
2417
2742
|
* The ergonomic entry point for the staging workspace lane. `use --auto` names the lane after the
|
|
2418
|
-
* checkout directory, which is what makes
|
|
2419
|
-
*
|
|
2743
|
+
* checkout directory, which is what makes one workspace file per worktree enough to give an agent its own
|
|
2744
|
+
* isolated staging lane on the SAME branch.
|
|
2420
2745
|
*/
|
|
2421
2746
|
async function workspaceCommand(flags) {
|
|
2422
2747
|
const cwd = process.cwd();
|
|
@@ -2600,6 +2925,12 @@ async function syncComponentsCommand(flags) {
|
|
|
2600
2925
|
forceTombstones,
|
|
2601
2926
|
dryRun,
|
|
2602
2927
|
mode: 'sync',
|
|
2928
|
+
// Identity and location, so the platform can take the landing lease for THIS session rather than for
|
|
2929
|
+
// an anonymous caller — and so a re-acquire by the same session (the CLI already took it in
|
|
2930
|
+
// `components commit` phase 1) succeeds instead of refusing itself.
|
|
2931
|
+
...runContextPayload(flags),
|
|
2932
|
+
agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
|
|
2933
|
+
acknowledge: flags.acknowledge || undefined,
|
|
2603
2934
|
components: []
|
|
2604
2935
|
};
|
|
2605
2936
|
|
|
@@ -2643,7 +2974,9 @@ async function syncComponentsCommand(flags) {
|
|
|
2643
2974
|
failOnSyncGate(preflightGate);
|
|
2644
2975
|
}
|
|
2645
2976
|
|
|
2646
|
-
|
|
2977
|
+
// Through the same refusal-aware door as `stage`: the landing lease and the account's commit rules both
|
|
2978
|
+
// answer 409 with the sentence to act on, and that sentence must reach the caller intact.
|
|
2979
|
+
const response = await stageOrRefuse(api, cwd, syncPayload);
|
|
2647
2980
|
|
|
2648
2981
|
if (!response.success) {
|
|
2649
2982
|
throw new Error(response.message || 'Server sync failed');
|
|
@@ -2663,6 +2996,7 @@ async function syncComponentsCommand(flags) {
|
|
|
2663
2996
|
printResolvedBaseUrl(baseUrl);
|
|
2664
2997
|
console.log('Data mode:', response.dataMode || dataMode);
|
|
2665
2998
|
console.log('Mode:', response.mode || 'sync');
|
|
2999
|
+
printComponentPolicy(response);
|
|
2666
3000
|
if (response.sync && response.sync.dryRun) {
|
|
2667
3001
|
console.log('Dry run: no variants were written and staging was not cleared.');
|
|
2668
3002
|
}
|
|
@@ -3343,6 +3677,17 @@ async function commitComponentsCommand(flags) {
|
|
|
3343
3677
|
throw new Error('components commit does not support --dry-run because it performs local git writes. Use `remits-cli components sync --dry-run`.');
|
|
3344
3678
|
}
|
|
3345
3679
|
|
|
3680
|
+
// Landing is serial and nothing can make it concurrent: `git add -A` sweeps a shared checkout, and the
|
|
3681
|
+
// platform pushes a regenerated `account-info.json` back to the branch during sync, so two commits
|
|
3682
|
+
// racing one branch collide on the remote.
|
|
3683
|
+
//
|
|
3684
|
+
// Taken HERE, before `git push`, and not after. Once the push has happened the platform can only refuse
|
|
3685
|
+
// the SYNC, and a pushed-but-unsynced branch is a state nobody is watching.
|
|
3686
|
+
//
|
|
3687
|
+
// The honest limit: this cannot stop a raw `git push` somebody runs themselves. It makes `remits-cli
|
|
3688
|
+
// components commit` serial, which is the path everything actually uses.
|
|
3689
|
+
const landing = await acquireLandingLease(flags, accountId, branchName);
|
|
3690
|
+
try {
|
|
3346
3691
|
if (!skipGit) {
|
|
3347
3692
|
console.log('Phase 1/3: local git commit/push');
|
|
3348
3693
|
const status = runGit(cwd, 'git status --porcelain');
|
|
@@ -3402,6 +3747,71 @@ async function commitComponentsCommand(flags) {
|
|
|
3402
3747
|
console.log('Verified local HEAD matches remits sync SHA:', expectedSha);
|
|
3403
3748
|
}
|
|
3404
3749
|
}
|
|
3750
|
+
} finally {
|
|
3751
|
+
// Give the branch back so the next agent lands in seconds rather than waiting out the TTL. Nothing
|
|
3752
|
+
// depends on this happening — the lease expires on its own, which is what covers the run that is
|
|
3753
|
+
// killed before it gets here.
|
|
3754
|
+
await releaseLandingLease(landing);
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
/**
|
|
3759
|
+
* Take the exclusive right to land this branch, or stop with the sentence saying who has it.
|
|
3760
|
+
*
|
|
3761
|
+
* Soft-fails on an OLDER PLATFORM that does not know the `land` mode (400/404): the CLI must keep working
|
|
3762
|
+
* against a platform it is ahead of, and the server takes the lease itself on the sync anyway — so the
|
|
3763
|
+
* worst case is that the refusal arrives one phase later than it should, not that it stops arriving.
|
|
3764
|
+
*/
|
|
3765
|
+
async function acquireLandingLease(flags, accountId, branchName) {
|
|
3766
|
+
const cwd = process.cwd();
|
|
3767
|
+
const { session } = resolveSessionContext(cwd, flags);
|
|
3768
|
+
const baseUrl = flags['base-url'] || session.baseUrl || DEFAULT_BASE_URL;
|
|
3769
|
+
const payload = {
|
|
3770
|
+
token: session.token,
|
|
3771
|
+
accountId,
|
|
3772
|
+
branchName,
|
|
3773
|
+
mode: 'land',
|
|
3774
|
+
dataMode: resolveDataMode(flags, session),
|
|
3775
|
+
...runContextPayload(flags),
|
|
3776
|
+
agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined
|
|
3777
|
+
};
|
|
3778
|
+
try {
|
|
3779
|
+
const response = await buildAxios(baseUrl, session.token, 20000)
|
|
3780
|
+
.post('/cli/components', payload).then((r) => r.data);
|
|
3781
|
+
if (response && response.success) {
|
|
3782
|
+
console.log('Landing lease: held on ' + branchName + ' (' +
|
|
3783
|
+
(response.ttlMinutes || '?') + 'm, renewed by the sync).');
|
|
3784
|
+
return { flags, accountId, branchName, baseUrl, session, held: true };
|
|
3785
|
+
}
|
|
3786
|
+
throw new Error((response && response.message) || 'Could not take the landing lease.');
|
|
3787
|
+
} catch (err) {
|
|
3788
|
+
const body = err && err.response && err.response.data;
|
|
3789
|
+
const status = err && err.response && err.response.status;
|
|
3790
|
+
if (body && body.message && status === 409) {
|
|
3791
|
+
throw new Error(body.message);
|
|
3792
|
+
}
|
|
3793
|
+
if (status === 400 || status === 404) {
|
|
3794
|
+
return { held: false };
|
|
3795
|
+
}
|
|
3796
|
+
if (body && body.message) {
|
|
3797
|
+
throw new Error(body.message);
|
|
3798
|
+
}
|
|
3799
|
+
throw err;
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
async function releaseLandingLease(landing) {
|
|
3804
|
+
if (!landing || !landing.held) return;
|
|
3805
|
+
try {
|
|
3806
|
+
await buildAxios(landing.baseUrl, landing.session.token, 20000).post('/cli/components', {
|
|
3807
|
+
token: landing.session.token,
|
|
3808
|
+
accountId: landing.accountId,
|
|
3809
|
+
branchName: landing.branchName,
|
|
3810
|
+
mode: 'unland',
|
|
3811
|
+
...runContextPayload(landing.flags),
|
|
3812
|
+
agentId: landing.flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined
|
|
3813
|
+
});
|
|
3814
|
+
} catch (_) { /* best-effort: the lease expires on its own */ }
|
|
3405
3815
|
}
|
|
3406
3816
|
|
|
3407
3817
|
function webSocketUrl(baseUrl) {
|
|
@@ -3509,6 +3919,7 @@ async function testCommand(flags) {
|
|
|
3509
3919
|
const dataMode = hasExplicitDataModeFlag(flags)
|
|
3510
3920
|
? resolveDataMode(flags, null)
|
|
3511
3921
|
: DEFAULT_DATA_MODE;
|
|
3922
|
+
const dataModeSource = dataModeFlagSource(flags);
|
|
3512
3923
|
const testRef = flags.test || flags['test-id'] || flags.name;
|
|
3513
3924
|
|
|
3514
3925
|
if (!testRef) {
|
|
@@ -3560,6 +3971,7 @@ async function testCommand(flags) {
|
|
|
3560
3971
|
branchName,
|
|
3561
3972
|
workspace,
|
|
3562
3973
|
dataMode,
|
|
3974
|
+
dataModeSource,
|
|
3563
3975
|
testId: /^\d+$/.test(String(testRef)) ? Number(testRef) : undefined,
|
|
3564
3976
|
testName: /^\d+$/.test(String(testRef)) ? undefined : String(testRef),
|
|
3565
3977
|
tests: names
|
|
@@ -5075,6 +5487,7 @@ async function collectAgentSnapshot() {
|
|
|
5075
5487
|
label: entry.label || entry.agentId,
|
|
5076
5488
|
alive: !!(worker.pid && processIsAlive(worker.pid))
|
|
5077
5489
|
}))), []),
|
|
5490
|
+
recentWorkers: recentWorkerRuns(24),
|
|
5078
5491
|
errors
|
|
5079
5492
|
};
|
|
5080
5493
|
agentSnapshotCache = { fetchedAtMs: nowMs, value };
|
|
@@ -5364,12 +5777,18 @@ function renderDashboardHtml(snapshot) {
|
|
|
5364
5777
|
'function supportTicketDetails(state,ticket,key){const agents=((state.agents||{}).registered)||[];const ticketAccountId=String(ticket.accountId||"");const eligible=agents.filter(function(a){if(ticket.baseUrl&&a.baseUrl&&a.baseUrl!==ticket.baseUrl)return false;if(ticket.dataMode&&a.dataMode&&a.dataMode!==ticket.dataMode)return false;return (a.accounts||[]).some(function(acc){return String(acc.accountId)===ticketAccountId||acc.routingScope==="self_and_descendants";});});const options=[\'<option value="">- unrouted -</option>\'].concat(eligible.map(function(a){const sel=a.agentId===ticket.routedAgentId?" selected":"";return \'<option value="\'+escapeHtml(a.agentId)+\'"\'+sel+\'>\'+escapeHtml((a.label||a.userName||a.agentId)+" - "+(a.state||"idle")+(a.activity?" ("+a.activity+")":""))+\'</option>\';})).join("");const routeControl=eligible.length?(\'<div class="mini-card"><strong>Route To Agent</strong><div style="display:flex;gap:8px;margin-top:6px;"><select data-route-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'" data-route-account="\'+escapeHtml(ticketAccountId)+\'" data-route-base="\'+escapeHtml(ticket.baseUrl||"")+\'" data-route-mode="\'+escapeHtml(ticket.dataMode||"")+\'" style="flex:1;min-width:0;">\'+options+\'</select><button class="action-btn" data-action="route-ticket" data-ticket="\'+escapeHtml(String(ticket.ticketId||""))+\'">Route</button></div></div>\'):\'<div class="mini-card"><strong>Route To Agent</strong>No registered agent covers this account.</div>\';const lane=ticket.dataMode||"prod";const planning=[ticket.workstream?("Workstream "+ticket.workstream):"",ticket.plannedIn?("Planned "+ticket.plannedIn):"",ticket.boardStage?("Board "+ticket.boardStage):"",ticket.rank!==undefined&&ticket.rank!==null&&ticket.rank!==""?("Rank "+ticket.rank):"",ticket.size?("Size "+ticket.size):"",ticket.blockedBy?("Blocked by "+ticket.blockedBy):""].filter(Boolean).join(" / ");const command="remits-cli ticket read --ticket "+String(ticket.ticketId||"")+" --data-mode "+lane+"\\nremits-cli ticket accept --ticket "+String(ticket.ticketId||"")+" --data-mode "+lane;return \'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(ticket.ticketId||"n/a")+\'</div><div class="mini-card"><strong>Account</strong>\'+escapeHtml((ticket.accountName||"n/a")+" ("+(ticket.accountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Type</strong>\'+escapeHtml(ticket.type||"n/a")+\'</div><div class="mini-card"><strong>Owned By</strong>\'+escapeHtml(ticket.assignedTo||"unassigned")+\'</div><div class="mini-card"><strong>Routed To</strong>\'+escapeHtml(ticket.routedAgentLabel||ticket.routedAgentId||"nobody")+\'</div><div class="mini-card"><strong>Implementation</strong>\'+escapeHtml((ticket.implementationAccountName||"n/a")+" ("+(ticket.implementationAccountId||"n/a")+")")+\'</div><div class="mini-card"><strong>Planning</strong>\'+escapeHtml(planning||"No planning fields")+\'</div></div>\'+routeControl+\'<div class="muted">\'+escapeHtml(ticket.affectedComponent||"No affected component recorded.")+\'</div>\'+codeBlock(key+":cmd",command)+\'</div>\';}' +
|
|
5365
5778
|
'function renderWebsockets(state){const entries=Object.entries((state.websocket&&state.websocket.connections)||{});const target=document.getElementById("websocket-section");if(!entries.length){target.innerHTML=\'<div class="empty">No websocket connections are registered yet.</div>\';return;}target.innerHTML=entries.map(function(pair){const baseUrl=pair[0];const info=pair[1]||{};const topicEntries=Object.entries(info.topics||{});const recentMessages=Array.isArray(info.recentMessages)?info.recentMessages:[];const tone=info.status==="connected"?"ok":(info.status==="connecting"?"warn":"bad");const summaryMeta=badge(info.status||"unknown",tone)+\' <span class="muted">\' + escapeHtml(String(topicEntries.length)) + \' topic(s)</span>\' + \' <span class="muted">\' + escapeHtml(String(recentMessages.length)) + \' recent message(s)</span>\';const topicBody=topicEntries.length?topicEntries.map(function(topicPair){const topic=topicPair[0];const topicInfo=topicPair[1]||{};const mini=\'<div class="mini-grid"><div class="mini-card"><strong>Accounts</strong>\'+escapeHtml((topicInfo.accountIds||[]).join(", ")||"n/a")+\'</div><div class="mini-card"><strong>Subscribed</strong>\'+escapeHtml(formatTime(topicInfo.subscribedAt))+\'</div><div class="mini-card"><strong>Last Message</strong>\'+escapeHtml(formatTime(topicInfo.lastMessageAt))+\'</div></div>\';return detailsBlock("ws-topic:"+baseUrl+":"+topic,topic,"topic subscription",mini,false);}).join(""):\'<div class="empty">No topic subscriptions recorded.</div>\';const messageBody=recentMessages.length?recentMessages.map(function(message,index){const messageMeta=badge(message.duplicate?"duplicate":"handled",message.duplicate?"warn":"ok")+\' <span class="muted">\'+escapeHtml(message.source||"live")+\'</span>\' + \' <span class="muted">\'+escapeHtml(message.type||"unknown-type")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Message ID</strong>\'+escapeHtml(message.messageId||"n/a")+\'</div><div class="mini-card"><strong>Timestamp</strong>\'+escapeHtml(message.timestamp?String(message.timestamp):"n/a")+\'</div><div class="mini-card"><strong>Received</strong>\'+escapeHtml(formatTime(message.receivedAt))+\'</div><div class="mini-card"><strong>Accounts</strong>\'+escapeHtml((message.accountIds||[]).join(", ")||"n/a")+\'</div></div>\'+codeBlock("ws-msg:"+baseUrl+":"+(message.messageId||index),JSON.stringify(message.payload||{},null,2)) + \'</div>\';return detailsBlock("ws-message:"+baseUrl+":"+(message.messageId||index),(message.type||"message")+" "+(message.messageId||""),messageMeta,body,false);}).join(""):\'<div class="empty">No recent websocket messages have been recorded yet.</div>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Status</strong>\'+escapeHtml(info.status||"unknown")+\'</div><div class="mini-card"><strong>Connected</strong>\'+escapeHtml(formatTime(info.connectedAt))+\'</div><div class="mini-card"><strong>Last Message</strong>\'+escapeHtml(formatTime(info.lastMessageAt))+\'</div><div class="mini-card"><strong>Last Error</strong>\'+escapeHtml(info.lastError||"none")+\'</div></div><div class="stack">\'+detailsBlock("ws-recent:"+baseUrl,"Recent messages","latest live and missed-message traffic",messageBody,false)+topicBody+\'</div></div>\';return detailsBlock("ws:"+baseUrl,baseUrl,summaryMeta,body,entries.length===1);}).join("");}' +
|
|
5366
5779
|
'function agentTone(agent){if(!agent||agent.online===false)return "bad";const s=agent.state||"idle";return s==="paused"?"warn":"ok";}' +
|
|
5367
|
-
'function
|
|
5780
|
+
'function basename(value){return String(value||"").split(/[\\\\/]/).pop();}' +
|
|
5781
|
+
'function workerRunKey(run){return run.logName||basename(run.logFile)||String(run.ticketId||"");}' +
|
|
5782
|
+
'function workerControls(run){const key=escapeHtml(workerRunKey(run));const ticket=escapeHtml(String(run.ticketId||""));const log=escapeHtml(run.logName||basename(run.logFile)||"");return \'<div class="wlog-bar"><button class="action-btn" data-follow-key="\'+key+\'" data-follow-ticket="\'+ticket+\'" data-follow-run="\'+log+\'">Follow</button><button class="action-btn" data-unfollow-key="\'+key+\'" disabled>Stop</button><label class="muted"><input type="checkbox" data-follow-tail="\'+key+\'" checked> stick to bottom</label><span class="muted" data-follow-status="\'+key+\'"></span></div><div class="wlog" data-follow-log="\'+key+\'"><div class="muted">Press <strong>Follow</strong> to stream this transcript.</div></div>\';}' +
|
|
5783
|
+
'function workerRunCard(run,index,prefix){const key=workerRunKey(run);const running=run.alive||run.running;const meta=badge(run.agentType||"worker",running?"ok":"warn")+" "+badge(running?"running":"finished",running?"ok":"warn")+" <span class=\\"muted\\">"+escapeHtml(formatTime(run.startedAt||run.updatedAt))+"</span>";const result=run.result?detailsBlock(prefix+":result:"+key,"Last message","worker closing summary",codeBlock(prefix+":result-code:"+key,run.result),false):"";const cardRows=[["Ticket",String(run.ticketId||"?")],["Doing Now",run.activity||"nothing reported"],["Started",formatTime(run.startedAt)],["Updated",formatTime(run.updatedAt)],["Pid",String(run.pid||"not running")],["Repo",run.workingDirectory||"(not recorded)"],["Target Branch",run.targetBranch||"n/a"],["Workspace",run.workspace||"n/a"],["Suggested Worktree",run.suggestedWorktree||"n/a"],["Log",run.logFile||""]];const cards=cardRows.map(function(pair){return "<div class=\\"mini-card\\"><strong>"+escapeHtml(pair[0])+"</strong>"+escapeHtml(pair[1])+"</div>";}).join("");const body="<div class=\\"stack\\"><div class=\\"mini-grid\\">"+cards+"</div>"+workerControls(run)+result+codeBlock(prefix+":tail:"+key,"tail -f "+(run.logFile||""))+"</div>";return detailsBlock(prefix+":"+key,"Ticket "+String(run.ticketId||"?")+" - "+(run.subject||basename(run.logFile)||"worker run"),meta,body,index===0);}' +
|
|
5784
|
+
'function renderWorkers(state){const target=document.getElementById("worker-section");const workers=((state.agents||{}).workers)||[];const recent=((state.agents||{}).recentWorkers)||[];let html=\'<div class="mini-grid"><div class="mini-card"><strong>Running Now</strong>\'+escapeHtml(String(workers.length))+\'</div><div class="mini-card"><strong>Recent Transcripts</strong>\'+escapeHtml(String(recent.length))+\'</div></div>\';html+=workers.length?detailsBlock("workers:live","Running workers","active headless ticket processes",workers.map(function(w,index){return workerRunCard(w,index,"worker-live");}).join(""),true):\'<div class="empty">No autonomous ticket workers are running right now.</div>\';html+=recent.length?detailsBlock("workers:recent","Recent worker runs","finished and running local transcripts",recent.map(function(w,index){return workerRunCard(w,index,"worker-history");}).join(""),true):\'<div class="empty">No worker transcripts have been recorded yet.</div>\';target.innerHTML=html;restartActiveFollows();}' +
|
|
5368
5785
|
'var followState={};' +
|
|
5369
5786
|
'function eventHtml(ev){if(!ev)return "";const t=escapeHtml(ev.text||"");if(ev.kind==="command"){const bad=(ev.exitCode!==null&&ev.exitCode!==undefined&&ev.exitCode!==0);const out=ev.detail?\'<span class="out">\'+escapeHtml(String(ev.detail).slice(0,2000))+\'</span>\':"";return \'<div class="ev cmd\'+(bad?" bad":"")+\'">$ \'+t+(bad?\' <span class="muted">(exit \'+escapeHtml(String(ev.exitCode))+\')</span>\':"")+out+\'</div>\';}if(ev.kind==="message")return \'<div class="ev msg">\'+t+\'</div>\';if(ev.kind==="thinking")return \'<div class="ev think">\'+t+\'</div>\';if(ev.kind==="edit")return \'<div class="ev edit">\'+t+\'</div>\';if(ev.kind==="result")return \'<div class="ev res">\'+t+\'</div>\';if(ev.kind==="raw")return \'<div class="ev raw">\'+t+\'</div>\';return \'<div class="ev">\'+t+\'</div>\';}' +
|
|
5370
|
-
'
|
|
5371
|
-
'function
|
|
5372
|
-
'function
|
|
5787
|
+
'function setFollowButtons(key,following){document.querySelectorAll(\'[data-follow-key="\'+key+\'"]\').forEach(function(b){b.disabled=following;});document.querySelectorAll(\'[data-unfollow-key="\'+key+\'"]\').forEach(function(b){b.disabled=!following;});}' +
|
|
5788
|
+
'async function pollFollow(key){const st=followState[key];if(!st)return;const box=document.querySelector(\'[data-follow-log="\'+key+\'"]\');const status=document.querySelector(\'[data-follow-status="\'+key+\'"]\');if(!box){stopFollow(key,true);return;}try{let url="/api/worker-log?ticket="+encodeURIComponent(st.ticket)+"&offset="+st.offset;if(st.logName)url+="&log="+encodeURIComponent(st.logName);const r=await fetch(url);const d=await r.json();if(d.missing){status&&(status.textContent="no transcript yet");}else{if(st.offset===0)box.innerHTML="";st.offset=d.offset||st.offset;const html=(d.events||[]).map(eventHtml).join("");if(html){box.insertAdjacentHTML("beforeend",html);const stick=document.querySelector(\'[data-follow-tail="\'+key+\'"]\');if(!stick||stick.checked)box.scrollTop=box.scrollHeight;}status&&(status.textContent=(d.running?"live":"finished")+" - "+(d.size||0)+" bytes");if(!d.running&&d.offset>=d.size){st.settleCount=(st.settleCount||0)+1;if(st.settleCount>2){stopFollow(key,false);return;}}else{st.settleCount=0;}}}catch(e){status&&(status.textContent="stream error");}}' +
|
|
5789
|
+
'function startFollow(key,ticket,logName){if(followState[key])stopFollow(key,true);followState[key]={ticket:ticket,logName:logName,offset:0,timer:null};setFollowButtons(key,true);const box=document.querySelector(\'[data-follow-log="\'+key+\'"]\');if(box)box.innerHTML="";pollFollow(key);followState[key].timer=setInterval(function(){pollFollow(key);},2000);}' +
|
|
5790
|
+
'function stopFollow(key,silent){const st=followState[key];if(st&&st.timer)clearInterval(st.timer);delete followState[key];setFollowButtons(key,false);const status=document.querySelector(\'[data-follow-status="\'+key+\'"]\');if(status&&!silent)status.textContent="stopped";}' +
|
|
5791
|
+
'function restartActiveFollows(){Object.keys(followState).forEach(function(key){const box=document.querySelector(\'[data-follow-log="\'+key+\'"]\');if(!box){stopFollow(key,true);return;}const st=followState[key];st.offset=0;box.innerHTML="";setFollowButtons(key,true);pollFollow(key);});}' +
|
|
5373
5792
|
'function renderAgents(state){const target=document.getElementById("agent-section");const info=state.agents||{};const agents=info.registered||[];const local=info.local||[];const errors=info.errors||[];let html=\'<div class="mini-grid"><div class="mini-card"><strong>Registered</strong>\'+escapeHtml(String(agents.length))+\'</div><div class="mini-card"><strong>On This Machine</strong>\'+escapeHtml(String(local.length))+\'</div><div class="mini-card"><strong>Fetched</strong>\'+escapeHtml(formatTime(info.fetchedAt))+\'</div></div>\';if(errors.length){html+=detailsBlock("agents:errors","Agent lookup errors",badge(String(errors.length)+" error(s)","warn"),codeBlock("agents-errors",JSON.stringify(errors,null,2),"log"),false);}if(!agents.length){target.innerHTML=html+\'<div class="empty">No local agent sessions are registered. In each terminal tab that should work tickets, run <strong>remits-cli agent register</strong>. Each tab becomes its own routable agent and stops receiving work when the tab closes.</div>\';return;}html+=agents.map(function(agent,index){const tone=agentTone(agent);const meta=badge(agent.state||"idle",tone)+\' \'+badge((agent.dataMode||"prod")+" lane",agent.dataMode==="test"?"warn":"ok")+\' <span class="muted">\'+escapeHtml(agent.baseUrl||"?")+\' · seen \'+escapeHtml(agent.secondsSinceSeen==null?"?":agent.secondsSinceSeen+"s ago")+\'</span>\';const accounts=(agent.accounts||[]).map(function(a){return (a.name||a.accountId)+" ("+a.accountId+(a.routingScope==="self_and_descendants"?", +descendants":"")+")";}).join(", ");const history=(agent.history||[]).map(function(h){return \'<li><span class="muted">\'+escapeHtml(formatTime(h.at))+\'</span> \'+escapeHtml((h.state||"")+(h.ticketId?" ticket "+h.ticketId:"")+(h.activity?" - "+h.activity:""))+\'</li>\';}).join("");const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Doing Now</strong>\'+escapeHtml(agent.activity||"nothing reported")+\'</div><div class="mini-card"><strong>Ticket</strong>\'+escapeHtml(agent.ticketId||"none")+\'</div><div class="mini-card"><strong>Step</strong>\'+escapeHtml(agent.step||"n/a")+\'</div><div class="mini-card"><strong>User</strong>\'+escapeHtml(agent.userEmail||agent.userName||"n/a")+\'</div><div class="mini-card"><strong>Where</strong>\'+escapeHtml((agent.host||"?")+":"+(agent.cwd||"?"))+\'</div><div class="mini-card"><strong>Tickets Routed</strong>\'+escapeHtml(String(agent.ticketsAssigned||0))+\'</div></div><div class="mini-card"><strong>Covers</strong>\'+escapeHtml(accounts||"none")+\'</div><div><strong style="display:block;margin:12px 0 8px 0;">Recent activity</strong><ul style="margin:0;padding-left:18px;display:grid;gap:6px;">\'+(history||"<li>Nothing recorded yet.</li>")+\'</ul></div></div>\';return detailsBlock("agent:"+agent.agentId,(agent.label||agent.userName||agent.agentId),meta,body,index===0);}).join("");target.innerHTML=html;}' +
|
|
5374
5793
|
'function renderActivity(state){const target=document.getElementById("activity-section");const logFile=(state.globalFiles||[]).find(function(file){return file.label==="activity.log (tail)";});target.innerHTML=detailsBlock("activity:log","Activity tail","service lifecycle, websocket, and agent events",codeBlock("activity-log",logFile&&logFile.content||"[missing]","log"),true);}' +
|
|
5375
5794
|
'function renderGlobalFiles(state){const target=document.getElementById("global-files");const files=(state.globalFiles||[]).filter(function(file){return file.label!=="activity.log (tail)";});if(!files.length){target.innerHTML=\'<div class="empty">No global files found.</div>\';return;}target.innerHTML=files.map(function(file){const tone=file.exists?"ok":"warn";const meta=badge(file.exists?"present":"missing",tone)+\' <span class="muted">\'+escapeHtml(file.updatedAt?formatTime(file.updatedAt):"not found")+\'</span>\';const body=\'<div class="stack"><div class="mini-grid"><div class="mini-card"><strong>Path</strong>\'+escapeHtml(file.path||"")+\'</div><div class="mini-card"><strong>Size</strong>\'+escapeHtml(String(file.size||0))+\' bytes</div><div class="mini-card"><strong>Updated</strong>\'+escapeHtml(formatTime(file.updatedAt))+\'</div></div>\'+codeBlock("global:"+file.label,file.content)+(file.label==="service-state.json"?\'<div class="muted">Use this file to discover the dashboard URL and runtime websocket summary.</div>\':"")+\'</div>\';return detailsBlock("global:"+file.label,file.label,meta,body,false);}).join("");}' +
|
|
@@ -5378,7 +5797,7 @@ function renderDashboardHtml(snapshot) {
|
|
|
5378
5797
|
'async function fetchState(){const res=await fetch("/api/state",{cache:"no-store"});if(!res.ok)throw new Error("State refresh failed with "+res.status);return res.json();}' +
|
|
5379
5798
|
'function showToast(message){const toast=document.getElementById("toast");toast.textContent=message;toast.classList.add("show");clearTimeout(showToast._timer);showToast._timer=setTimeout(function(){toast.classList.remove("show");},2400);}' +
|
|
5380
5799
|
'async function triggerAction(action,button,extra){if(actionInFlight)return;actionInFlight=true;button.disabled=true;try{let body="action="+encodeURIComponent(action);Object.keys(extra||{}).forEach(function(k){body+="&"+encodeURIComponent(k)+"="+encodeURIComponent(extra[k]==null?"":extra[k]);});const res=await fetch("/action",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:body});if(!res.ok)throw new Error("Action failed with "+res.status);const payload=await res.json();renderAll(payload.state);if(payload.result&&payload.result.bundlePath){showToast("Issue bundle prepared at "+payload.result.bundlePath);}else{showToast(action.replace(/-/g," ")+" complete");}}catch(err){showToast(err.message||String(err));}finally{actionInFlight=false;button.disabled=false;}}' +
|
|
5381
|
-
'document.addEventListener("click",function(event){const follow=event.target.closest?event.target.closest("[data-follow-
|
|
5800
|
+
'document.addEventListener("click",function(event){const stop=event.target.closest?event.target.closest("[data-unfollow-key]"):null;if(stop){stopFollow(stop.getAttribute("data-unfollow-key"),false);return;}const follow=event.target.closest?event.target.closest("[data-follow-key]"):null;if(follow){startFollow(follow.getAttribute("data-follow-key"),follow.getAttribute("data-follow-ticket"),follow.getAttribute("data-follow-run"));return;}const button=event.target.closest?event.target.closest("[data-action]"):null;if(!button)return;const action=button.getAttribute("data-action");let extra=null;if(action==="route-ticket"){const ticketId=button.getAttribute("data-ticket");const select=document.querySelector(\'[data-route-ticket="\'+ticketId+\'"]\');if(!select){showToast("No agent selector for that ticket.");return;}extra={ticketId:ticketId,accountId:select.getAttribute("data-route-account"),agentId:select.value,baseUrl:select.getAttribute("data-route-base"),dataMode:select.getAttribute("data-route-mode")};}triggerAction(action,button,extra);});' +
|
|
5382
5801
|
'renderAll(dashboardState);' +
|
|
5383
5802
|
'setInterval(function(){fetchState().then(renderAll).catch(function(err){showToast(err.message||String(err));});},refreshMs);' +
|
|
5384
5803
|
'})();' +
|
|
@@ -5577,6 +5996,118 @@ function workerTranscriptFor(ticketId, fromOffset) {
|
|
|
5577
5996
|
};
|
|
5578
5997
|
}
|
|
5579
5998
|
|
|
5999
|
+
function workerRunFromLogName(name) {
|
|
6000
|
+
const match = /^(\d+)-(\d+)\.log$/.exec(String(name || ''));
|
|
6001
|
+
if (!match) {
|
|
6002
|
+
return null;
|
|
6003
|
+
}
|
|
6004
|
+
return { ticketId: match[1], timestampMs: Number(match[2]) || null };
|
|
6005
|
+
}
|
|
6006
|
+
|
|
6007
|
+
function resultTextForWorkerLog(logFile) {
|
|
6008
|
+
const parsed = workerRunFromLogName(path.basename(logFile || ''));
|
|
6009
|
+
if (!parsed) {
|
|
6010
|
+
return null;
|
|
6011
|
+
}
|
|
6012
|
+
const exact = logFile.replace(/\.log$/, '.result.txt');
|
|
6013
|
+
if (fs.existsSync(exact)) {
|
|
6014
|
+
return truncateText(fs.readFileSync(exact, 'utf8'), 4000);
|
|
6015
|
+
}
|
|
6016
|
+
const candidates = fs.existsSync(WORKER_LOG_DIR)
|
|
6017
|
+
? fs.readdirSync(WORKER_LOG_DIR)
|
|
6018
|
+
.filter((name) => name.startsWith(parsed.ticketId + '-') && name.endsWith('.result.txt'))
|
|
6019
|
+
.map((name) => {
|
|
6020
|
+
const file = path.join(WORKER_LOG_DIR, name);
|
|
6021
|
+
const stat = fileStat(file);
|
|
6022
|
+
return stat ? { file, mtimeMs: stat.mtimeMs } : null;
|
|
6023
|
+
})
|
|
6024
|
+
.filter(Boolean)
|
|
6025
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
6026
|
+
: [];
|
|
6027
|
+
if (!candidates.length) {
|
|
6028
|
+
return null;
|
|
6029
|
+
}
|
|
6030
|
+
return truncateText(fs.readFileSync(candidates[0].file, 'utf8'), 4000);
|
|
6031
|
+
}
|
|
6032
|
+
|
|
6033
|
+
/** Recent worker transcripts, including runs whose live worker row was cleared after release/exit. */
|
|
6034
|
+
function recentWorkerRuns(limit = 20) {
|
|
6035
|
+
if (!fs.existsSync(WORKER_LOG_DIR)) {
|
|
6036
|
+
return [];
|
|
6037
|
+
}
|
|
6038
|
+
|
|
6039
|
+
const activeByLog = {};
|
|
6040
|
+
for (const agent of listLocalAgents()) {
|
|
6041
|
+
for (const worker of listWorkers(agent.agentId)) {
|
|
6042
|
+
if (worker && worker.logFile) {
|
|
6043
|
+
activeByLog[path.basename(worker.logFile)] = {
|
|
6044
|
+
...worker,
|
|
6045
|
+
agentId: agent.agentId,
|
|
6046
|
+
label: agent.label || agent.agentId
|
|
6047
|
+
};
|
|
6048
|
+
}
|
|
6049
|
+
}
|
|
6050
|
+
}
|
|
6051
|
+
|
|
6052
|
+
return fs.readdirSync(WORKER_LOG_DIR)
|
|
6053
|
+
.map((name) => {
|
|
6054
|
+
const parsed = workerRunFromLogName(name);
|
|
6055
|
+
if (!parsed) {
|
|
6056
|
+
return null;
|
|
6057
|
+
}
|
|
6058
|
+
const logFile = path.join(WORKER_LOG_DIR, name);
|
|
6059
|
+
const stat = fileStat(logFile);
|
|
6060
|
+
if (!stat) {
|
|
6061
|
+
return null;
|
|
6062
|
+
}
|
|
6063
|
+
const active = activeByLog[name] || {};
|
|
6064
|
+
const alive = !!(active.pid && processIsAlive(active.pid));
|
|
6065
|
+
return {
|
|
6066
|
+
ticketId: parsed.ticketId,
|
|
6067
|
+
logName: name,
|
|
6068
|
+
logFile,
|
|
6069
|
+
size: stat.size,
|
|
6070
|
+
updatedAt: stat.mtime.toISOString(),
|
|
6071
|
+
startedAt: active.startedAt || (parsed.timestampMs ? new Date(parsed.timestampMs).toISOString() : null),
|
|
6072
|
+
agentId: active.agentId || null,
|
|
6073
|
+
label: active.label || null,
|
|
6074
|
+
agentType: active.agentType || null,
|
|
6075
|
+
pid: active.pid || null,
|
|
6076
|
+
alive,
|
|
6077
|
+
running: alive,
|
|
6078
|
+
attempt: active.attempt || null,
|
|
6079
|
+
workingDirectory: active.workingDirectory || null,
|
|
6080
|
+
targetBranch: active.targetBranch || null,
|
|
6081
|
+
suggestedWorktree: active.suggestedWorktree || null,
|
|
6082
|
+
workspace: active.workspace || null,
|
|
6083
|
+
subject: active.subject || null,
|
|
6084
|
+
result: resultTextForWorkerLog(logFile)
|
|
6085
|
+
};
|
|
6086
|
+
})
|
|
6087
|
+
.filter(Boolean)
|
|
6088
|
+
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
|
|
6089
|
+
.slice(0, Math.max(1, Number(limit) || 20));
|
|
6090
|
+
}
|
|
6091
|
+
|
|
6092
|
+
/** The transcript of one specific worker log, addressed by safe basename. */
|
|
6093
|
+
function workerTranscriptByLogName(logName, fromOffset) {
|
|
6094
|
+
const parsed = workerRunFromLogName(path.basename(String(logName || '')));
|
|
6095
|
+
if (!parsed) {
|
|
6096
|
+
return { error: 'a valid worker log name is required' };
|
|
6097
|
+
}
|
|
6098
|
+
const logFile = path.join(WORKER_LOG_DIR, path.basename(logName));
|
|
6099
|
+
const transcript = readWorkerTranscript(logFile, fromOffset);
|
|
6100
|
+
return {
|
|
6101
|
+
ticketId: parsed.ticketId,
|
|
6102
|
+
logName: path.basename(logName),
|
|
6103
|
+
logFile,
|
|
6104
|
+
running: false,
|
|
6105
|
+
pid: null,
|
|
6106
|
+
follow: 'tail -f ' + logFile,
|
|
6107
|
+
...transcript
|
|
6108
|
+
};
|
|
6109
|
+
}
|
|
6110
|
+
|
|
5580
6111
|
function startDashboardServer(preferredPort) {
|
|
5581
6112
|
return new Promise((resolve, reject) => {
|
|
5582
6113
|
const sendDashboardError = (res, err, wantsJson = true) => {
|
|
@@ -5670,9 +6201,11 @@ function startDashboardServer(preferredPort) {
|
|
|
5670
6201
|
|
|
5671
6202
|
if (req.method === 'GET' && requestUrl.pathname === '/api/worker-log') {
|
|
5672
6203
|
try {
|
|
5673
|
-
const
|
|
5674
|
-
|
|
5675
|
-
|
|
6204
|
+
const offset = Number(requestUrl.searchParams.get('offset')) || 0;
|
|
6205
|
+
const logName = requestUrl.searchParams.get('log');
|
|
6206
|
+
const payload = logName
|
|
6207
|
+
? workerTranscriptByLogName(logName, offset)
|
|
6208
|
+
: workerTranscriptFor(requestUrl.searchParams.get('ticket'), offset);
|
|
5676
6209
|
res.statusCode = 200;
|
|
5677
6210
|
res.setHeader('Content-Type', 'application/json');
|
|
5678
6211
|
res.end(JSON.stringify(payload));
|
|
@@ -6132,6 +6665,7 @@ const AGENT_STATES = ['idle', 'working', 'paused'];
|
|
|
6132
6665
|
// ---------------------------------------------------------------------------------------------
|
|
6133
6666
|
|
|
6134
6667
|
const WORKER_LOG_DIR = path.join(SESSION_DIR, 'workers');
|
|
6668
|
+
const TICKET_WORKTREE_DIRNAME = 'ticket-worktrees';
|
|
6135
6669
|
const AGENT_SERVE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_POLL_MS || 20000);
|
|
6136
6670
|
const AGENT_SERVE_IDLE_POLL_MS = Number(process.env.REMITS_CLI_AGENT_SERVE_IDLE_POLL_MS || 60000);
|
|
6137
6671
|
// After this many consecutive empty polls, back off to the idle cadence. An agent waiting all night
|
|
@@ -6400,6 +6934,57 @@ function resolveTicketWorkingDirectory(ticket, ownCwd = null) {
|
|
|
6400
6934
|
return null;
|
|
6401
6935
|
}
|
|
6402
6936
|
|
|
6937
|
+
function gitBranchNameInDirectory(cwd) {
|
|
6938
|
+
if (!cwd) return null;
|
|
6939
|
+
try {
|
|
6940
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
6941
|
+
cwd,
|
|
6942
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
6943
|
+
}).toString().trim();
|
|
6944
|
+
return branch && branch !== 'HEAD' ? branch : null;
|
|
6945
|
+
} catch (_) {
|
|
6946
|
+
return null;
|
|
6947
|
+
}
|
|
6948
|
+
}
|
|
6949
|
+
|
|
6950
|
+
function defaultGitBranchInDirectory(cwd) {
|
|
6951
|
+
if (!cwd) return null;
|
|
6952
|
+
try {
|
|
6953
|
+
const branch = execSync('git symbolic-ref --short refs/remotes/origin/HEAD', {
|
|
6954
|
+
cwd,
|
|
6955
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
6956
|
+
}).toString().trim().replace(/^[^/]+\//, '');
|
|
6957
|
+
return branch && branch !== 'HEAD' ? branch : null;
|
|
6958
|
+
} catch (_) {
|
|
6959
|
+
return null;
|
|
6960
|
+
}
|
|
6961
|
+
}
|
|
6962
|
+
|
|
6963
|
+
function ticketTargetBranch(ticket, baseDirectory) {
|
|
6964
|
+
const facts = (ticket && ticket.briefFacts) || {};
|
|
6965
|
+
const candidates = [
|
|
6966
|
+
facts.componentBranch,
|
|
6967
|
+
ticket && ticket.componentBranch,
|
|
6968
|
+
facts.branch,
|
|
6969
|
+
ticket && ticket.branch,
|
|
6970
|
+
defaultGitBranchInDirectory(baseDirectory),
|
|
6971
|
+
gitBranchNameInDirectory(baseDirectory)
|
|
6972
|
+
];
|
|
6973
|
+
for (const candidate of candidates) {
|
|
6974
|
+
const branch = candidate == null ? '' : String(candidate).trim();
|
|
6975
|
+
if (branch && branch !== 'HEAD') return branch;
|
|
6976
|
+
}
|
|
6977
|
+
// No guess. This used to fall back to 'main', which is a guess presented to the worker as a fact — and
|
|
6978
|
+
// wrong on any repo whose default is `master`, or whose work belongs on a variant branch. A worker told
|
|
6979
|
+
// the wrong target branch does not fail; it does the work in the wrong place and reports success.
|
|
6980
|
+
// Absent is a real answer, and the brief omits the line rather than naming a branch nobody resolved.
|
|
6981
|
+
return null;
|
|
6982
|
+
}
|
|
6983
|
+
|
|
6984
|
+
function ticketWorktreePath(baseDirectory, ticketId) {
|
|
6985
|
+
return path.join(baseDirectory, '.remits-cli', TICKET_WORKTREE_DIRNAME, 'ticket-' + ticketId);
|
|
6986
|
+
}
|
|
6987
|
+
|
|
6403
6988
|
/**
|
|
6404
6989
|
* Which account this directory is a checkout OF, read from the directory itself.
|
|
6405
6990
|
*
|
|
@@ -6510,6 +7095,21 @@ function processIsAlive(pid) {
|
|
|
6510
7095
|
}
|
|
6511
7096
|
}
|
|
6512
7097
|
|
|
7098
|
+
async function waitForProcessExit(pid, timeoutMs = 5000) {
|
|
7099
|
+
const numeric = Number(pid);
|
|
7100
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
7101
|
+
return true;
|
|
7102
|
+
}
|
|
7103
|
+
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
|
|
7104
|
+
while (Date.now() < deadline) {
|
|
7105
|
+
if (!processIsAlive(numeric)) {
|
|
7106
|
+
return true;
|
|
7107
|
+
}
|
|
7108
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
7109
|
+
}
|
|
7110
|
+
return !processIsAlive(numeric);
|
|
7111
|
+
}
|
|
7112
|
+
|
|
6513
7113
|
/**
|
|
6514
7114
|
* The locally-known agent sessions, dropping any whose anchor process has gone.
|
|
6515
7115
|
*
|
|
@@ -6713,6 +7313,23 @@ function agentAccountClaims(cwd) {
|
|
|
6713
7313
|
return claims;
|
|
6714
7314
|
}
|
|
6715
7315
|
|
|
7316
|
+
/**
|
|
7317
|
+
* `--serves support,incident` -> ['support','incident'].
|
|
7318
|
+
*
|
|
7319
|
+
* Opaque strings: an account's workstream names are its own and neither the CLI nor the platform learns
|
|
7320
|
+
* what any of them means. Lower-cased and de-duplicated here so one machine's `--serves SDLC` and
|
|
7321
|
+
* another's `--serves sdlc` are the same declaration rather than two queues that never overlap.
|
|
7322
|
+
*/
|
|
7323
|
+
function parseServedWorkstreams(value) {
|
|
7324
|
+
if (value === undefined || value === null || value === true) {
|
|
7325
|
+
return [];
|
|
7326
|
+
}
|
|
7327
|
+
return String(value)
|
|
7328
|
+
.split(',')
|
|
7329
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
7330
|
+
.filter((entry, index, all) => entry && all.indexOf(entry) === index);
|
|
7331
|
+
}
|
|
7332
|
+
|
|
6716
7333
|
async function agentRegisterCommand(flags, options = {}) {
|
|
6717
7334
|
const cwd = process.cwd();
|
|
6718
7335
|
try { updateAccountRepoIndex(cwd); } catch (_) { /* not in an account repo */ }
|
|
@@ -6756,6 +7373,15 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
6756
7373
|
// platform stops routing past it, so a supervisor at capacity is not handed work it cannot start.
|
|
6757
7374
|
maxConcurrent: options.serve ? options.serve.maxConcurrent : (flags['max-concurrent'] ? parsePositiveInt(flags['max-concurrent'], 1) : undefined),
|
|
6758
7375
|
autonomous: options.serve ? true : undefined,
|
|
7376
|
+
// The autonomy bound and the queues this session covers, on the PRESENCE record rather than only on
|
|
7377
|
+
// the work poll. Routing chooses from presence, so without these `dispatch(...)` would hand a code
|
|
7378
|
+
// change to a session launched read-only while an edit-capable one sat idle, and would ignore
|
|
7379
|
+
// `--serves` entirely. Both are omitted rather than sent empty when not declared, so a re-register
|
|
7380
|
+
// after a TTL lapse cannot silently un-specialise a machine.
|
|
7381
|
+
mode: options.serve ? options.serve.mode : undefined,
|
|
7382
|
+
serves: (options.serve && options.serve.serves && options.serve.serves.length)
|
|
7383
|
+
? options.serve.serves
|
|
7384
|
+
: (parseServedWorkstreams(flags.serves).length ? parseServedWorkstreams(flags.serves) : undefined),
|
|
6759
7385
|
state: flags.state && AGENT_STATES.includes(String(flags.state)) ? String(flags.state) : 'idle',
|
|
6760
7386
|
activity: flags.activity || null,
|
|
6761
7387
|
source: flags.source || 'repo'
|
|
@@ -6793,6 +7419,7 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
6793
7419
|
if (options.serve) {
|
|
6794
7420
|
entry.workerAgent = options.serve.workerAgent;
|
|
6795
7421
|
entry.mode = options.serve.mode;
|
|
7422
|
+
entry.serves = options.serve.serves || [];
|
|
6796
7423
|
entry.maxConcurrent = options.serve.maxConcurrent;
|
|
6797
7424
|
}
|
|
6798
7425
|
rememberLocalAgent(entry);
|
|
@@ -6828,6 +7455,9 @@ async function agentRegisterCommand(flags, options = {}) {
|
|
|
6828
7455
|
console.log(' worker ' + serve.workerAgent + ' (' + serve.mode + ' mode)');
|
|
6829
7456
|
console.log(' ' + serve.workerAgentWhy);
|
|
6830
7457
|
console.log(' capacity ' + serve.maxConcurrent + ' ticket(s) at a time');
|
|
7458
|
+
console.log(' serves ' + ((serve.serves && serve.serves.length)
|
|
7459
|
+
? serve.serves.join(', ') + ' (only these workstreams are routed here, and only these are swept)'
|
|
7460
|
+
: 'every workstream on these accounts'));
|
|
6831
7461
|
console.log('');
|
|
6832
7462
|
console.log('When a ticket is routed here it launches a fresh ' + serve.workerAgent + ' process for that');
|
|
6833
7463
|
console.log('ticket, in that account\'s repo, and that process exits when the ticket is done — so no');
|
|
@@ -6871,6 +7501,16 @@ async function agentServeCommand(flags) {
|
|
|
6871
7501
|
}
|
|
6872
7502
|
const mode = String(flags.mode || (flagEnabled(flags['investigate-only']) ? 'investigate' : 'edit'))
|
|
6873
7503
|
.toLowerCase() === 'investigate' ? 'investigate' : 'edit';
|
|
7504
|
+
const serves = parseServedWorkstreams(flags.serves);
|
|
7505
|
+
if (serves.length) {
|
|
7506
|
+
console.log('');
|
|
7507
|
+
console.log('Serving workstreams: ' + serves.join(', '));
|
|
7508
|
+
console.log(' This session is offered ONLY tickets in those workstreams, and its sweep takes only');
|
|
7509
|
+
console.log(' those. A workstream no live session covers is routed to nobody — the platform reports');
|
|
7510
|
+
console.log(' that as no_agent_serves_workstream rather than quietly handing it here anyway.');
|
|
7511
|
+
console.log(' Drop --serves to serve every workstream on these accounts.');
|
|
7512
|
+
console.log('');
|
|
7513
|
+
}
|
|
6874
7514
|
|
|
6875
7515
|
// Say what this supervisor CANNOT work, before it starts rather than one ticket at a time. A worker
|
|
6876
7516
|
// pointed at an account with no usable checkout parks correctly, but the operator only learns that
|
|
@@ -6906,6 +7546,7 @@ async function agentServeCommand(flags) {
|
|
|
6906
7546
|
workerAgent,
|
|
6907
7547
|
workerAgentWhy: choice.why,
|
|
6908
7548
|
mode,
|
|
7549
|
+
serves,
|
|
6909
7550
|
maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1))
|
|
6910
7551
|
}
|
|
6911
7552
|
});
|
|
@@ -6980,7 +7621,11 @@ async function agentWorkersCommand(flags) {
|
|
|
6980
7621
|
console.log(' ' + (worker.subject || '(no subject)'));
|
|
6981
7622
|
console.log(' since ' + worker.startedAt + ' attempt ' + (worker.attempt || 1));
|
|
6982
7623
|
console.log(' in ' + (worker.workingDirectory || '(no repo resolved)') +
|
|
7624
|
+
(worker.targetBranch ? ' target branch ' + worker.targetBranch : '') +
|
|
6983
7625
|
(worker.workspace ? ' workspace ' + worker.workspace : ''));
|
|
7626
|
+
if (worker.suggestedWorktree) {
|
|
7627
|
+
console.log(' suggest ' + worker.suggestedWorktree);
|
|
7628
|
+
}
|
|
6984
7629
|
console.log(' doing ' + (worker.activity || 'nothing reported yet'));
|
|
6985
7630
|
console.log(' log ' + worker.logFile);
|
|
6986
7631
|
}
|
|
@@ -7017,6 +7662,13 @@ function startAgentHeartbeatDaemon(entry, serve = null) {
|
|
|
7017
7662
|
'--worker-agent', serve.workerAgent,
|
|
7018
7663
|
'--mode', serve.mode,
|
|
7019
7664
|
'--max-concurrent', String(serve.maxConcurrent));
|
|
7665
|
+
// The daemon is DETACHED: nothing about its process tree recovers what the operator typed, so any
|
|
7666
|
+
// declaration that must survive into the supervisor loop has to travel on the command line. Omitted
|
|
7667
|
+
// when empty so the daemon's own `parseServedWorkstreams` sees "not declared" rather than "declared
|
|
7668
|
+
// as nothing", which are different facts on the registration payload.
|
|
7669
|
+
if (serve.serves && serve.serves.length) {
|
|
7670
|
+
args.push('--serves', serve.serves.join(','));
|
|
7671
|
+
}
|
|
7020
7672
|
}
|
|
7021
7673
|
const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env: { ...process.env } });
|
|
7022
7674
|
child.unref();
|
|
@@ -7196,8 +7848,8 @@ async function postAgentStatus(context, agentId, update) {
|
|
|
7196
7848
|
* Launch one headless agent process for one ticket, and see it through to its exit.
|
|
7197
7849
|
*
|
|
7198
7850
|
* The brief comes from the platform ({@code SupportTicketBrief}) so all three agent types are told
|
|
7199
|
-
* the same thing
|
|
7200
|
-
*
|
|
7851
|
+
* the same thing. Only the local supervisor can add machine-local repository hints; the worker owns the
|
|
7852
|
+
* final grounding step, including creating or choosing a git worktree before it edits.
|
|
7201
7853
|
*/
|
|
7202
7854
|
function spawnTicketWorker(context, ctx, ticket) {
|
|
7203
7855
|
const { spawn } = require('child_process');
|
|
@@ -7212,18 +7864,61 @@ function spawnTicketWorker(context, ctx, ticket) {
|
|
|
7212
7864
|
return { pid: null, done: Promise.resolve({ ticketId, ok: false, reason: 'unsupported_agent' }) };
|
|
7213
7865
|
}
|
|
7214
7866
|
|
|
7215
|
-
const
|
|
7867
|
+
const baseWorkingDirectory = resolveTicketWorkingDirectory(ticket, ctx && ctx.cwd);
|
|
7868
|
+
const repositoryWork = !((ticket && ticket.requiresRepositoryWork === false) ||
|
|
7869
|
+
(ticket && ticket.briefFacts && ticket.briefFacts.requiresRepositoryWork === false));
|
|
7870
|
+
const targetBranch = repositoryWork ? ticketTargetBranch(ticket, baseWorkingDirectory) : gitBranchNameInDirectory(baseWorkingDirectory);
|
|
7871
|
+
const suggestedWorktree = repositoryWork && baseWorkingDirectory ? ticketWorktreePath(baseWorkingDirectory, ticketId) : null;
|
|
7872
|
+
const workingDirectory = baseWorkingDirectory;
|
|
7216
7873
|
// The brief is rendered server-side without the local path (the platform cannot know it), so the
|
|
7217
|
-
// resolved
|
|
7874
|
+
// resolved local repository hint is appended here. When nothing resolved, the server-rendered brief already
|
|
7218
7875
|
// tells the worker to find it rather than guess — do not paper over that with a default.
|
|
7219
7876
|
// The staging lane is set BY THIS FUNCTION (REMITS_WORKSPACE below) and the worker has no other way to
|
|
7220
7877
|
// learn it: `components status` would show it a lane it never chose, and anything it reports about "the
|
|
7221
7878
|
// staged code" is ambiguous until it knows which lane it means. Telling it is one line; making it guess
|
|
7222
7879
|
// is the whole class of defect the lane exists to remove.
|
|
7223
7880
|
const workerWorkspace = 'ticket-' + ticketId;
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7881
|
+
// A branch nobody resolved is OMITTED, never guessed: a worker told the wrong target branch does not
|
|
7882
|
+
// fail, it does the work in the wrong place and reports success.
|
|
7883
|
+
const localWorktreeText = workingDirectory && repositoryWork
|
|
7884
|
+
? '\n\n## Local repository grounding\n\n' +
|
|
7885
|
+
'The supervisor resolved the repository account to this local checkout:\n\n`' + workingDirectory + '`\n\n' +
|
|
7886
|
+
(targetBranch
|
|
7887
|
+
? 'Target git/component branch: `' + targetBranch + '`.\n'
|
|
7888
|
+
: 'No target branch resolved. Read `remits-cli ticket where --ticket ' + ticketId + '` and the\n' +
|
|
7889
|
+
'repository itself before you decide which branch this work belongs on — do not assume `main`.\n') +
|
|
7890
|
+
'Suggested per-ticket worktree path: `' + suggestedWorktree + '`.\n\n' +
|
|
7891
|
+
'Before editing, establish your own isolated git worktree for this ticket and move into it, so the\n' +
|
|
7892
|
+
'operator\'s launch checkout is not modified underneath them. **You own this decision** — the\n' +
|
|
7893
|
+
'supervisor is a plain local process and cannot reason about repository state. The command below is\n' +
|
|
7894
|
+
'a starting point, not a script to follow blindly; it must start from the remote branch tip, not a\n' +
|
|
7895
|
+
'stale local ref:\n\n' +
|
|
7896
|
+
'```\n' +
|
|
7897
|
+
(targetBranch
|
|
7898
|
+
? 'git -C ' + shellQuote(workingDirectory) + ' fetch origin ' + shellQuote(targetBranch) + '\n'
|
|
7899
|
+
: 'git -C ' + shellQuote(workingDirectory) + ' fetch origin\n') +
|
|
7900
|
+
'git -C ' + shellQuote(workingDirectory) + ' worktree add --force ' + shellQuote(suggestedWorktree) +
|
|
7901
|
+
(targetBranch ? ' ' + targetBranch : ' <branch>') + '\n' +
|
|
7902
|
+
'cd ' + shellQuote(suggestedWorktree) + '\n' +
|
|
7903
|
+
(targetBranch
|
|
7904
|
+
? 'git pull --ff-only origin ' + shellQuote(targetBranch) + '\n' +
|
|
7905
|
+
'git rev-list --left-right --count HEAD...origin/' + shellQuote(targetBranch) + ' # must print: 0 0\n'
|
|
7906
|
+
: 'git pull --ff-only origin <branch>\n' +
|
|
7907
|
+
'git rev-list --left-right --count HEAD...origin/<branch> # must print: 0 0\n') +
|
|
7908
|
+
'remits-cli components status\n' +
|
|
7909
|
+
'```\n\n' +
|
|
7910
|
+
'If the pull cannot fast-forward, or either side of that rev-list count is non-zero, stop and settle\n' +
|
|
7911
|
+
'the branch before editing. A worktree and workspace isolate this worker from the launch checkout and\n' +
|
|
7912
|
+
'from other staging lanes; they do not prove which commit the files are based on.\n\n' +
|
|
7913
|
+
'If git still refuses because the branch is already checked out elsewhere, add `--detach`, and in THAT case\n' +
|
|
7914
|
+
'only, export `REMITS_GIT_BRANCH=' + (targetBranch || '<branch>') + '` so the branch this run reports\n' +
|
|
7915
|
+
'(staging scope, landing lease, run context) matches the branch it is working — git cannot name a\n' +
|
|
7916
|
+
'branch for a detached worktree, so nothing else can answer. On a normal worktree do not set it and\n' +
|
|
7917
|
+
'do not need to: git is asked first and the variable is ignored whenever git has a real answer.\n'
|
|
7918
|
+
: (workingDirectory
|
|
7919
|
+
? '\n\n## Resolved local repository\n\n`' + workingDirectory + '`\n'
|
|
7920
|
+
: '');
|
|
7921
|
+
const brief = String(ticket.brief || '') + localWorktreeText +
|
|
7227
7922
|
'\n\n## Your staging lane\n\n' +
|
|
7228
7923
|
'Every `remits-cli` command you run stages into the **`' + workerWorkspace + '`** workspace lane, not the\n' +
|
|
7229
7924
|
'shared one. This is set for you; you do not need to pass `--workspace`. It means your staged components\n' +
|
|
@@ -7238,6 +7933,12 @@ function spawnTicketWorker(context, ctx, ticket) {
|
|
|
7238
7933
|
// worker spent its run fighting a restriction nobody had mentioned. The platform now returns the phase
|
|
7239
7934
|
// it derived from the repository's edit lease, and that is the authority. The session's own --mode can
|
|
7240
7935
|
// only narrow it: an operator who asked for a read-only agent gets one even when the lease is free.
|
|
7936
|
+
//
|
|
7937
|
+
// `working` is a THIRD phase, and it must fall on the edit side of this branch: it means the account
|
|
7938
|
+
// declared that this ticket touches no repository, so no lease was taken and none was refused. A run
|
|
7939
|
+
// like that is not restricted — it simply has no checkout to be exclusive about. Testing for
|
|
7940
|
+
// `!== 'investigating'` rather than `=== 'editing'` is what keeps a new phase from silently demoting
|
|
7941
|
+
// every non-repository worker to read-only.
|
|
7241
7942
|
const effectiveMode = (ctx.mode === 'investigate' || ticket.phase === 'investigating')
|
|
7242
7943
|
? 'investigate'
|
|
7243
7944
|
: 'edit';
|
|
@@ -7290,15 +7991,22 @@ function spawnTicketWorker(context, ctx, ticket) {
|
|
|
7290
7991
|
sandbox: built.sandbox || null,
|
|
7291
7992
|
startedAt: new Date().toISOString(),
|
|
7292
7993
|
workingDirectory: workingDirectory || null,
|
|
7994
|
+
targetBranch: targetBranch || null,
|
|
7995
|
+
suggestedWorktree: suggestedWorktree || null,
|
|
7293
7996
|
accountId: ticket.accountId || null,
|
|
7294
7997
|
subject: ticket.subject || null,
|
|
7295
7998
|
logFile
|
|
7296
7999
|
});
|
|
7297
8000
|
appendGlobalActivityLog('agent', 'worker.started', {
|
|
7298
8001
|
agentId, ticketId, agentType, pid: child.pid, attempt, workingDirectory: workingDirectory || null,
|
|
8002
|
+
targetBranch: targetBranch || null,
|
|
8003
|
+
suggestedWorktree: suggestedWorktree || null,
|
|
7299
8004
|
// Which phase a run started in is the first thing anyone asks when its output looks thin, so it is
|
|
7300
8005
|
// on the record rather than inferable from a sandbox flag nobody logged.
|
|
7301
|
-
phase
|
|
8006
|
+
// Report the phase the PLATFORM derived, not the one this launcher collapsed it to: an operator
|
|
8007
|
+
// reading `editing` on a ticket that never involved a repository would go looking for a lease that
|
|
8008
|
+
// was correctly never taken.
|
|
8009
|
+
phase: effectiveMode === 'investigate' ? 'investigating' : (ticket.phase || 'editing'),
|
|
7302
8010
|
editLease: ticket.editLease ? { agentId: ticket.editLease.agentId, ticketId: ticket.editLease.ticketId } : null
|
|
7303
8011
|
});
|
|
7304
8012
|
if (ticket.phase === 'investigating' && ticket.editLeaseMessage) {
|
|
@@ -7429,6 +8137,7 @@ async function agentHeartbeatDaemonCommand(flags) {
|
|
|
7429
8137
|
// worker kind must come from the flag it was started with, never from re-detection.
|
|
7430
8138
|
workerAgent: workerAgentType(flags['worker-agent'] || flags.agent),
|
|
7431
8139
|
mode: String(flags.mode || 'edit').toLowerCase() === 'investigate' ? 'investigate' : 'edit',
|
|
8140
|
+
serves: parseServedWorkstreams(flags.serves),
|
|
7432
8141
|
maxConcurrent: Math.max(1, parsePositiveInt(flags['max-concurrent'], 1)),
|
|
7433
8142
|
runId: 'run-' + Date.now().toString(36)
|
|
7434
8143
|
};
|
|
@@ -7534,6 +8243,31 @@ async function agentHeartbeatDaemonCommand(flags) {
|
|
|
7534
8243
|
});
|
|
7535
8244
|
}
|
|
7536
8245
|
|
|
8246
|
+
// A supervisor sitting idle beside a full queue is either broken or doing exactly what it was
|
|
8247
|
+
// told, and those look identical from outside. Logged ONCE per stretch of empty polls, because a
|
|
8248
|
+
// reason repeated every few seconds is a log nobody reads.
|
|
8249
|
+
const sweep = (response && response.sweep) || {};
|
|
8250
|
+
if (emptyPolls === 0) {
|
|
8251
|
+
if ((sweep.needsHumanTicketIds || []).length) {
|
|
8252
|
+
appendGlobalActivityLog('agent', 'serve.needs_human', {
|
|
8253
|
+
agentId,
|
|
8254
|
+
ticketIds: sweep.needsHumanTicketIds,
|
|
8255
|
+
message: sweep.needsHumanTicketIds.length + ' ticket(s) have been through this account\'s ' +
|
|
8256
|
+
'autonomous-run limit (' + sweep.autonomousRunLimit + ') and are no longer offered to ' +
|
|
8257
|
+
'workers. Answer, reopen or route one by hand: remits-cli ticket queue --min-runs 1'
|
|
8258
|
+
}, 'warn');
|
|
8259
|
+
}
|
|
8260
|
+
if ((sweep.outOfScopeTicketIds || []).length) {
|
|
8261
|
+
appendGlobalActivityLog('agent', 'serve.out_of_scope', {
|
|
8262
|
+
agentId,
|
|
8263
|
+
ticketIds: sweep.outOfScopeTicketIds,
|
|
8264
|
+
message: sweep.outOfScopeTicketIds.length + ' unrouted ticket(s) are outside the workstreams ' +
|
|
8265
|
+
'this session serves (' + (sweep.serves || []).join(', ') + '). That is the declaration ' +
|
|
8266
|
+
'working; another session has to cover them.'
|
|
8267
|
+
});
|
|
8268
|
+
}
|
|
8269
|
+
}
|
|
8270
|
+
|
|
7537
8271
|
for (const ticket of (response && response.tickets) || []) {
|
|
7538
8272
|
const ticketId = String(ticket.id || ticket.ticketId || '');
|
|
7539
8273
|
if (!ticketId || inFlight.has(ticketId)) {
|
|
@@ -7564,7 +8298,12 @@ async function agentHeartbeatDaemonCommand(flags) {
|
|
|
7564
8298
|
'max-concurrent': String(ctx.maxConcurrent),
|
|
7565
8299
|
json: 'true'
|
|
7566
8300
|
},
|
|
7567
|
-
|
|
8301
|
+
// Re-register with THIS supervisor's declared bound and queues. The platform treats an absent
|
|
8302
|
+
// `mode`/`serves` as "unchanged", which is right for a heartbeat against a live record and wrong
|
|
8303
|
+
// here: this path runs precisely because the record EXPIRED, so there is nothing left to be
|
|
8304
|
+
// unchanged from, and a specialised machine would come back as a generalist that can edit.
|
|
8305
|
+
{ spawnHeartbeat: false,
|
|
8306
|
+
serve: { mode: ctx.mode, serves: ctx.serves, maxConcurrent: ctx.maxConcurrent } }
|
|
7568
8307
|
).catch((err) => {
|
|
7569
8308
|
appendGlobalActivityLog('agent', 'heartbeat.reregister_failed',
|
|
7570
8309
|
{ agentId, error: describeError(err) }, 'error');
|
|
@@ -7700,6 +8439,28 @@ async function agentWorkCommand(flags) {
|
|
|
7700
8439
|
* record, so the same commands work on every Remits platform — which is what an autonomous worker
|
|
7701
8440
|
* needs, since its brief has to be written before anyone knows which platform it will run on.
|
|
7702
8441
|
*/
|
|
8442
|
+
/**
|
|
8443
|
+
* Say that this ticket is governed, and say it loudly when the policy could not be parsed.
|
|
8444
|
+
*
|
|
8445
|
+
* A worker that only ever learns a rule exists by being refused by it has already wasted the call that
|
|
8446
|
+
* hit it — the same argument that inlines the rules into a worker brief instead of pointing at them.
|
|
8447
|
+
*/
|
|
8448
|
+
function printTicketPolicy(response) {
|
|
8449
|
+
const policy = response && response.policy;
|
|
8450
|
+
if (!policy) return;
|
|
8451
|
+
if (policy.error) {
|
|
8452
|
+
console.error('POLICY NOT ENFORCED: this account has support-ticket rules that could not be parsed: ' +
|
|
8453
|
+
policy.error);
|
|
8454
|
+
console.error(' Report this on the ticket. Do not read the absence of a refusal as permission.');
|
|
8455
|
+
return;
|
|
8456
|
+
}
|
|
8457
|
+
if (policy.ruleCount) {
|
|
8458
|
+
console.log('Policy: ' + policy.ruleCount +
|
|
8459
|
+
(policy.ruleCount === 1 ? ' rule governs' : ' rules govern') +
|
|
8460
|
+
' this ticket (' + (policy.ruleIds || []).join(', ') + ').');
|
|
8461
|
+
}
|
|
8462
|
+
}
|
|
8463
|
+
|
|
7703
8464
|
async function ticketCommand(flags, subcommand) {
|
|
7704
8465
|
const action = String(subcommand || 'read').toLowerCase();
|
|
7705
8466
|
|
|
@@ -7779,6 +8540,15 @@ async function ticketCommand(flags, subcommand) {
|
|
|
7779
8540
|
size: planningFlagValue(flags.size),
|
|
7780
8541
|
blockedBy: planningFlagValue(firstDefined(flags['blocked-by'], flags.blockedBy)),
|
|
7781
8542
|
workstream: planningFlagValue(flags.workstream),
|
|
8543
|
+
// Relations and commitments clear the same way a planning slot does — the flag with no value — so
|
|
8544
|
+
// `--caused-by` alone detaches a ticket from its cluster rather than being read as "no change".
|
|
8545
|
+
duplicateOf: planningFlagValue(firstDefined(flags['duplicate-of'], flags.duplicateOf)),
|
|
8546
|
+
causedBy: planningFlagValue(firstDefined(flags['caused-by'], flags.causedBy)),
|
|
8547
|
+
dueAt: planningFlagValue(firstDefined(flags['due-at'], flags.due, flags.dueAt)),
|
|
8548
|
+
respondBy: planningFlagValue(firstDefined(flags['respond-by'], flags.respondBy)),
|
|
8549
|
+
// Tri-state on the wire: 'true'/'false' are the two answers and absent is "not saying", which the
|
|
8550
|
+
// platform reads as "keeps today's behaviour". Coercing here would erase the third state.
|
|
8551
|
+
requiresRepositoryWork: firstDefined(flags['repo-work'], flags['requires-repository-work']),
|
|
7782
8552
|
tags: flags.tags ? String(flags.tags).split(',').map((v) => v.trim()).filter(Boolean) : undefined,
|
|
7783
8553
|
label: flags.label,
|
|
7784
8554
|
url: flags.url,
|
|
@@ -7805,6 +8575,11 @@ async function ticketCommand(flags, subcommand) {
|
|
|
7805
8575
|
agentId: flags['agent-id'] || process.env.REMITS_AGENT_ID || undefined,
|
|
7806
8576
|
runId: flags['run-id'] || process.env.REMITS_WORKER_RUN_ID || undefined,
|
|
7807
8577
|
agentLabel: flags['agent-label'] || undefined,
|
|
8578
|
+
// The acknowledgement that gets a caller past a `require` rule. It is deliberately NOT enough on its
|
|
8579
|
+
// own: the platform honours it only for an actor the rule permits, and it decides "human" from the
|
|
8580
|
+
// ABSENCE of a run identity — which a worker process always sends. So a headless worker passing this
|
|
8581
|
+
// is refused, and told so. The override, when it is honoured, is written to the ticket worklog.
|
|
8582
|
+
acknowledge: flags.acknowledge || undefined,
|
|
7808
8583
|
// WHERE this run is working, sent with every lifecycle call so the lease and the claim record THIS
|
|
7809
8584
|
// process's checkout rather than the supervisor's. A ticket worker runs in its own workspace lane
|
|
7810
8585
|
// (REMITS_WORKSPACE=ticket-<id>) and may run in its own worktree, so the two genuinely differ.
|
|
@@ -7837,6 +8612,8 @@ async function ticketCommand(flags, subcommand) {
|
|
|
7837
8612
|
return;
|
|
7838
8613
|
}
|
|
7839
8614
|
|
|
8615
|
+
printTicketPolicy(response);
|
|
8616
|
+
|
|
7840
8617
|
if (flagEnabled(flags.json) || action === 'read' || action === 'deliveries') {
|
|
7841
8618
|
console.log(JSON.stringify(response, null, 2));
|
|
7842
8619
|
return;
|
|
@@ -7953,6 +8730,20 @@ async function ticketQueueCommand(flags) {
|
|
|
7953
8730
|
boardStage: flags['board-stage'] || flags.boardStage,
|
|
7954
8731
|
blockedBy: flags['blocked-by'] || flags.blockedBy,
|
|
7955
8732
|
search: flags.search || flags.q,
|
|
8733
|
+
// Every one of these reads a field the platform already mirrored and already projected, and that
|
|
8734
|
+
// no surface could ask about. `syncState` in particular is how a broken integration is found:
|
|
8735
|
+
// a failed sync retries forever, so it sits in pendingSync looking exactly like a healthy one.
|
|
8736
|
+
syncStates: csv(flags['sync-state'] || flags.syncState),
|
|
8737
|
+
overdue: flagEnabled(flags.overdue) || undefined,
|
|
8738
|
+
relatedTo: flags['related-to'] || flags.relatedTo,
|
|
8739
|
+
duplicateOf: flags['duplicate-of'] || flags.duplicateOf,
|
|
8740
|
+
causedBy: flags['caused-by'] || flags.causedBy,
|
|
8741
|
+
hasCause: flagEnabled(flags['has-cause'] || flags.hasCause) || undefined,
|
|
8742
|
+
dueBefore: flags['due-before'] || flags.dueBefore,
|
|
8743
|
+
dueAfter: flags['due-after'] || flags.dueAfter,
|
|
8744
|
+
// "Which tickets has the fleet already tried and handed back." A mirrored field with no filter is a
|
|
8745
|
+
// mirrored field nobody can act on — the same defect awaitingResponse and externalSyncState each had.
|
|
8746
|
+
minAutonomousRuns: parsePositiveInt(flags['min-runs'] || flags.minRuns, 0) || undefined,
|
|
7956
8747
|
sortBy: flags['sort-by'] || flags.sortBy,
|
|
7957
8748
|
sortDirection: flags['sort-direction'] || flags.sortDirection,
|
|
7958
8749
|
includeChildren: flags['include-children'] === 'false' ? false : undefined,
|
|
@@ -7973,7 +8764,15 @@ async function ticketQueueCommand(flags) {
|
|
|
7973
8764
|
console.log(String(response.totalCount != null ? response.totalCount : tickets.length) +
|
|
7974
8765
|
' ticket(s) — ' + (counts.unrouted || 0) + ' unrouted, ' + (counts.unassigned || 0) + ' unassigned' +
|
|
7975
8766
|
', ' + (counts.awaitingResponse || 0) + ' waiting on a human' +
|
|
8767
|
+
((counts.needsHuman || 0) ? ', ' + counts.needsHuman + ' NEEDS A PERSON' : '') +
|
|
7976
8768
|
' [' + (response.dataMode || '?') + ' lane]');
|
|
8769
|
+
if (counts.needsHuman) {
|
|
8770
|
+
// The one count that measures a backlog the automation deliberately created. Without it the fleet
|
|
8771
|
+
// reports healthy activity while the queue quietly stops going down.
|
|
8772
|
+
console.log(' ' + counts.needsHuman + ' ticket(s) have been through this account\'s autonomous-run limit ' +
|
|
8773
|
+
'and are no longer offered to workers.');
|
|
8774
|
+
console.log(' Find them: remits-cli ticket queue --min-runs 1 --unassigned (then answer, reopen, or route one by hand)');
|
|
8775
|
+
}
|
|
7977
8776
|
console.log('');
|
|
7978
8777
|
// Board position is printed only when some ticket in this queue actually has one. An always-on column
|
|
7979
8778
|
// would be blank for every account that does not run a board, and a blank column reads as missing data.
|
|
@@ -7991,6 +8790,7 @@ async function ticketQueueCommand(flags) {
|
|
|
7991
8790
|
// and truncating `awaiting_customer_confirmation` to fit a column makes it unrecognisable.
|
|
7992
8791
|
const marks = [
|
|
7993
8792
|
t.awaitingResponseSince ? 'WAITING ON A HUMAN' : '',
|
|
8793
|
+
t.autonomousRuns > 1 ? 'run ' + t.autonomousRuns + ' by workers' : '',
|
|
7994
8794
|
showsBoard && t.boardStage ? 'stage ' + t.boardStage : '',
|
|
7995
8795
|
showsBoard && t.plannedIn ? 'in ' + t.plannedIn : '',
|
|
7996
8796
|
showsBoard && t.blockedBy ? 'BLOCKED BY ' + t.blockedBy : ''
|
|
@@ -8159,6 +8959,19 @@ function printTicketHelp() {
|
|
|
8159
8959
|
console.log(' --planned-in --blocked-by --sort-by --sort-direction --limit --json');
|
|
8160
8960
|
console.log(' --awaiting-response lists only tickets parked on a human. A status filter cannot');
|
|
8161
8961
|
console.log(' ask that: `ask` parks a ticket in pending_review, which also means "done, review me".');
|
|
8962
|
+
console.log(' --overdue lists tickets past a due time their account committed to; --sort-by due');
|
|
8963
|
+
console.log(' ranks by it (soonest first; undated sorts last, in BOTH directions).');
|
|
8964
|
+
console.log(' --sync-state failed finds tickets that cannot reach the system this account syncs');
|
|
8965
|
+
console.log(' with. A failed sync retries forever, so it is indistinguishable from a healthy');
|
|
8966
|
+
console.log(' pending one in `pendingSync` — this is how a broken integration is found before the');
|
|
8967
|
+
console.log(' partner reports it.');
|
|
8968
|
+
console.log(' --related-to ID lists the cluster around one ticket; --has-cause lists every ticket');
|
|
8969
|
+
console.log(' that defers to another one.');
|
|
8970
|
+
console.log(' --min-runs N finds tickets autonomous workers have already picked up N times and');
|
|
8971
|
+
console.log(' handed back. Past this account\'s limit the sweep stops offering them, so they are a');
|
|
8972
|
+
console.log(' backlog the automation deliberately created and nothing else surfaces: answer one');
|
|
8973
|
+
console.log(' (`ticket answer`), reopen it, or route it by hand — an explicit route is never');
|
|
8974
|
+
console.log(' vetoed by the limit.');
|
|
8162
8975
|
console.log(' create --subject "..." --type defect|question|task|incident|enhancement');
|
|
8163
8976
|
console.log(' Raise a ticket. Use it when you find a SECOND problem while working one — file it');
|
|
8164
8977
|
console.log(' rather than widening the ticket you were given. [--priority --description --tags');
|
|
@@ -8199,8 +9012,9 @@ function printTicketHelp() {
|
|
|
8199
9012
|
console.log(' reopen | assign --assignee EMAIL');
|
|
8200
9013
|
console.log(' route --agent AGENT_ID Hand it to a registered agent session.');
|
|
8201
9014
|
console.log(' unroute Clear its routing, e.g. when that session is gone.');
|
|
8202
|
-
console.log(' tag --tags a,b Mark it
|
|
8203
|
-
console.log('
|
|
9015
|
+
console.log(' tag --tags a,b Mark it, for a HUMAN reading the queue. When the');
|
|
9016
|
+
console.log(' tickets genuinely share a cause, use `relate` instead or as well: the platform reads');
|
|
9017
|
+
console.log(' that one, and a tag it cannot read will not stop a cluster being worked N times.');
|
|
8204
9018
|
console.log(' artifact --type TYPE --label "..." [--url U | --content "..."]');
|
|
8205
9019
|
console.log(' Attach evidence — a log, a query result, a repro. Better than pasting it into a');
|
|
8206
9020
|
console.log(' worklog summary, where it is neither typed nor retrievable.');
|
|
@@ -8212,6 +9026,20 @@ function printTicketHelp() {
|
|
|
8212
9026
|
console.log(' planning [--workstream V] [--planned-in V] [--board-stage V] [--rank N] [--size V] [--blocked-by V]');
|
|
8213
9027
|
console.log(' Update organization-owned planning fields without changing platform lifecycle status.');
|
|
8214
9028
|
console.log(' Pass a flag with no value to CLEAR that slot, e.g. --blocked-by');
|
|
9029
|
+
console.log(' relate [--caused-by ID | --duplicate-of ID] [--reason "..."]');
|
|
9030
|
+
console.log(' Say this ticket is part of a CLUSTER: N tickets, N requesters, one root cause.');
|
|
9031
|
+
console.log(' Unlike a tag, the platform READS it — a ticket whose cause is being worked is not');
|
|
9032
|
+
console.log(' handed out again while that work runs, so a cluster costs one worker and not N.');
|
|
9033
|
+
console.log(' --duplicate-of: no separate work at all. --caused-by: this requester still needs');
|
|
9034
|
+
console.log(' their own outcome once the cause is settled. A flag with no value clears it.');
|
|
9035
|
+
console.log(' due --due-at 2026-09-08T17:00:00Z [--respond-by ...]');
|
|
9036
|
+
console.log(' Record the times YOUR organization committed to. The platform stores, sorts and');
|
|
9037
|
+
console.log(' filters these and never acts on them: no timer, no escalation. Build those as an');
|
|
9038
|
+
console.log(' Action sweeping `ticket queue --overdue`.');
|
|
9039
|
+
console.log(' repo-work --repo-work false');
|
|
9040
|
+
console.log(' Declare that this ticket does not involve the repository — a documented answer, or');
|
|
9041
|
+
console.log(' something only a person can do. A worker then takes no repository edit lease for it');
|
|
9042
|
+
console.log(' and blocks nobody who needs the checkout. Absent means it DOES; nothing is inferred.');
|
|
8215
9043
|
console.log(' force-unlease [--reason "..."] Break a stale repository lease somebody else holds.');
|
|
8216
9044
|
console.log(' For an operator only: unlease returns YOUR lease, this one takes away theirs, and it');
|
|
8217
9045
|
console.log(' records a handoff worklog entry on the ticket so the intervention is visible.');
|
|
@@ -8250,6 +9078,23 @@ function runContextPayload(flags = {}) {
|
|
|
8250
9078
|
* and they are opposite facts: the shared lane is exactly the one where concurrent agents overwrite
|
|
8251
9079
|
* each other's staged components.
|
|
8252
9080
|
*/
|
|
9081
|
+
/**
|
|
9082
|
+
* What a session was launched to do: the workstreams it covers and whether it may edit.
|
|
9083
|
+
*
|
|
9084
|
+
* Returns null for an unrestricted, edit-capable session — the default — so the line only ever appears
|
|
9085
|
+
* when there IS a restriction to know about. A line that renders for everybody is a line nobody reads.
|
|
9086
|
+
*/
|
|
9087
|
+
function describeAgentScope(record) {
|
|
9088
|
+
if (!record) return null;
|
|
9089
|
+
const serves = Array.isArray(record.serves) ? record.serves.filter(Boolean) : [];
|
|
9090
|
+
const readOnly = record.mode === 'investigate' || record.canEdit === false;
|
|
9091
|
+
if (!serves.length && !readOnly) return null;
|
|
9092
|
+
const parts = [];
|
|
9093
|
+
parts.push(serves.length ? 'serves ' + serves.join(', ') : 'serves every workstream');
|
|
9094
|
+
parts.push(readOnly ? 'investigate only (never edits or stages)' : 'may edit');
|
|
9095
|
+
return parts.join(' · ');
|
|
9096
|
+
}
|
|
9097
|
+
|
|
8253
9098
|
function describeRunLocation(record) {
|
|
8254
9099
|
if (!record) return '';
|
|
8255
9100
|
const dir = record.workingDirectory || record.cwd || record.claimedWorkingDirectory;
|
|
@@ -8325,6 +9170,11 @@ async function agentWorkMapCommand(flags) {
|
|
|
8325
9170
|
(agent.secondsSinceSeen != null ? ' seen ' + agent.secondsSinceSeen + 's ago' : ''));
|
|
8326
9171
|
const at = describeRunLocation(agent);
|
|
8327
9172
|
if (at) console.log(' ' + at);
|
|
9173
|
+
// What this session is FOR, beside where it is. An operator reading a map of idle machines beside a
|
|
9174
|
+
// full queue needs to see that they are idle BY DECLARATION — read-only, or serving other
|
|
9175
|
+
// workstreams — not that routing is broken.
|
|
9176
|
+
const bound = describeAgentScope(agent);
|
|
9177
|
+
if (bound) console.log(' ' + bound);
|
|
8328
9178
|
if (agent.activity) console.log(' ' + String(agent.activity).slice(0, 100));
|
|
8329
9179
|
}
|
|
8330
9180
|
console.log('');
|
|
@@ -8382,6 +9232,7 @@ async function agentListCommand(flags) {
|
|
|
8382
9232
|
console.log(' lane ' + (agent.dataMode || 'prod') +
|
|
8383
9233
|
' capacity ' + (agent.activeTicketCount || 0) + '/' + (agent.maxConcurrent || 1) +
|
|
8384
9234
|
(agent.hasCapacity === false ? ' (at capacity — not routable)' : ''));
|
|
9235
|
+
console.log(' serves ' + (describeAgentScope(agent) || 'every workstream, and may edit'));
|
|
8385
9236
|
if (agent.activity) console.log(' doing ' + agent.activity);
|
|
8386
9237
|
// Through describeRunLocation, which reads BOTH the canonical `workingDirectory` and the historical
|
|
8387
9238
|
// `cwd`: a record written by an older platform, or still in Redis from before this field was
|
|
@@ -8507,6 +9358,7 @@ async function agentReleaseCommand(flags) {
|
|
|
8507
9358
|
if (daemonPid) {
|
|
8508
9359
|
workersStopped = listWorkers(agentId).length;
|
|
8509
9360
|
try { process.kill(daemonPid, 'SIGTERM'); } catch (_) { /* already gone */ }
|
|
9361
|
+
await waitForProcessExit(daemonPid, 5000);
|
|
8510
9362
|
}
|
|
8511
9363
|
|
|
8512
9364
|
const context = agentSessionFor(flags, local);
|
|
@@ -8542,6 +9394,7 @@ function printAgentHelp() {
|
|
|
8542
9394
|
console.log('Usage: remits-cli agent <subcommand>');
|
|
8543
9395
|
console.log('');
|
|
8544
9396
|
console.log(' serve [--worker-agent claude|codex|gemini] [--max-concurrent N] [--mode edit|investigate]');
|
|
9397
|
+
console.log(' [--serves support,incident,sdlc]');
|
|
8545
9398
|
console.log(' Run this in a PLAIN TERMINAL TAB — that tab becomes the agent host. It returns');
|
|
8546
9399
|
console.log(' immediately; a background supervisor launches ONE fresh headless agent process per');
|
|
8547
9400
|
console.log(' routed ticket, in that account\'s repo, and that process exits when the ticket is');
|
|
@@ -8552,10 +9405,17 @@ function printAgentHelp() {
|
|
|
8552
9405
|
console.log(' session is running (when you start it from inside one), else your');
|
|
8553
9406
|
console.log(' `remits-cli config set --agent NAME` default. It prints which it chose and why.');
|
|
8554
9407
|
console.log(' --mode investigate runs workers read-only (no file edits).');
|
|
9408
|
+
console.log(' --serves declares which WORKSTREAMS this machine covers, so a fleet can be a set of');
|
|
9409
|
+
console.log(' queues rather than interchangeable machines: --serves sdlc on one box, --serves');
|
|
9410
|
+
console.log(' support,incident on another. Only those tickets are routed here and only those are');
|
|
9411
|
+
console.log(' swept. Omit it to serve everything, which is the default. A workstream no live');
|
|
9412
|
+
console.log(' session covers goes unrouted and is reported as no_agent_serves_workstream — the');
|
|
9413
|
+
console.log(' declaration is honoured rather than quietly overridden, so check `agent map` after');
|
|
9414
|
+
console.log(' specialising a fleet.');
|
|
8555
9415
|
console.log('');
|
|
8556
9416
|
console.log(' workers [--json] What this session is running right now.');
|
|
8557
9417
|
console.log('');
|
|
8558
|
-
console.log(' register [--label NAME] [--data-mode test|prod] [--base-url URL]');
|
|
9418
|
+
console.log(' register [--label NAME] [--data-mode test|prod] [--base-url URL] [--serves a,b]');
|
|
8559
9419
|
console.log(' Presence only — make THIS session routable without starting anything. Use it when a');
|
|
8560
9420
|
console.log(' human, or the agent in this very tab, will work the tickets by hand. Runs from ANY');
|
|
8561
9421
|
console.log(' directory — it registers for every account repo indexed on this machine that your');
|
|
@@ -9140,6 +10000,15 @@ async function main() {
|
|
|
9140
10000
|
if (autoUpdateIfNeeded(originalArgv, { requireSuccess: requireSuccessfulAutoUpdate })) return;
|
|
9141
10001
|
}
|
|
9142
10002
|
|
|
10003
|
+
// Refresh the installed skill on EVERY command, not just from inside an account repo. This runs
|
|
10004
|
+
// after the auto-update re-exec, so it is the new version writing the new skill. It was previously
|
|
10005
|
+
// hung off updateAccountRepoIndex(), which returns early outside a repo — so a user who upgraded and
|
|
10006
|
+
// then ran `remits-cli agent serve` from a plain tab (the documented way to work tickets, explicitly
|
|
10007
|
+
// "no need to be in an account repo") kept the PREVIOUS skill on disk indefinitely: a stale index
|
|
10008
|
+
// pointing at reference files that the upgrade had just renamed or removed. Reading and comparing the
|
|
10009
|
+
// whole corpus across all three agent targets measures ~3ms, so there is nothing to save by guarding it.
|
|
10010
|
+
try { syncInstalledSkills(); } catch (_) { /* best-effort */ }
|
|
10011
|
+
|
|
9143
10012
|
// Best-effort: update account-repo index whenever running from inside a remits repo
|
|
9144
10013
|
try { updateAccountRepoIndex(process.cwd()); } catch (_) { /* not in account repo */ }
|
|
9145
10014
|
// Auto-start the background service if not already running.
|
|
@@ -9226,8 +10095,13 @@ async function main() {
|
|
|
9226
10095
|
console.log(' - only which staged entries shadow them changes');
|
|
9227
10096
|
console.log('');
|
|
9228
10097
|
console.log('Typical setup, one worktree per agent, all on the same branch:');
|
|
9229
|
-
console.log(' git
|
|
10098
|
+
console.log(' git fetch origin');
|
|
10099
|
+
console.log(' git switch forked');
|
|
10100
|
+
console.log(' git pull --ff-only origin forked');
|
|
10101
|
+
console.log(' git rev-list --left-right --count HEAD...origin/forked # must print: 0 0');
|
|
10102
|
+
console.log(' git worktree add --force ../ms-agent-a forked');
|
|
9230
10103
|
console.log(' cd ../ms-agent-a && remits-cli workspace use --auto');
|
|
10104
|
+
console.log(' remits-cli components status # branch world + staging lane + other lanes');
|
|
9231
10105
|
console.log(' remits-cli components stage # isolated lane; nobody else sees it');
|
|
9232
10106
|
console.log(' remits-cli test run --test 42 # resolves this lane over the forked variants');
|
|
9233
10107
|
console.log(' remits-cli token --path /page/x # its own browser token, in this lane');
|
|
@@ -9407,6 +10281,13 @@ async function main() {
|
|
|
9407
10281
|
}
|
|
9408
10282
|
|
|
9409
10283
|
main().catch((err) => {
|
|
10284
|
+
// A REFUSAL is an answer, not a fault. The platform's sentence already says exactly what is wrong and
|
|
10285
|
+
// what to do instead; wrapping it in "remits-cli error:" and a stack invites the caller to retry the
|
|
10286
|
+
// identical call, which is the one thing a refused caller must not do.
|
|
10287
|
+
if (err && err.refused) {
|
|
10288
|
+
console.error('Refused: ' + err.message);
|
|
10289
|
+
process.exit(1);
|
|
10290
|
+
}
|
|
9410
10291
|
if (process.env.REMITS_CLI_DEBUG_STACK === '1' && err && err.stack) {
|
|
9411
10292
|
console.error(err.stack);
|
|
9412
10293
|
}
|