This is an automated email from the ASF dual-hosted git repository.

rombert pushed a commit to branch feat/llmao
in repository https://gitbox.apache.org/repos/asf/sling-tooling-jenkins.git


The following commit(s) were added to refs/heads/feat/llmao by this push:
     new 986951e  feat: validate result deterministically
986951e is described below

commit 986951e6e46d64d30f775573a82cb9cd8e932717
Author: Robert Munteanu <[email protected]>
AuthorDate: Wed Oct 14 09:57:42 2026 +0100

    feat: validate result deterministically
---
 README.md                                          |  38 ++++----
 .../org/apache/sling/jenkins/llmao/parent-pom.py   | 101 +++++++++++++++++++++
 .../org/apache/sling/jenkins/llmao/pi-events.mjs   |  57 ++----------
 .../org/apache/sling/jenkins/llmao/run-docker.sh   |  19 +---
 resources/org/apache/sling/jenkins/llmao/run-pi.sh |   8 +-
 .../apache/sling/jenkins/llmao/run-verification.sh |  48 ++++++++++
 .../org/apache/sling/jenkins/llmao/verify-build.sh |  28 ++++++
 vars/slingOsgiBundleBuild.groovy                   |  69 +++++++++++---
 8 files changed, 275 insertions(+), 93 deletions(-)

diff --git a/README.md b/README.md
index c7071bf..3995dc9 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ When the branch job related to the modified `Jenkinsfile` is 
built, it will use
 
 ## Calling Apache LLMAO from Jenkins
 
-`slingOsgiBundleBuild(llmaoOnly: true)` runs a separate Pi automation job, not 
the normal Maven matrix, SonarCloud analysis, deployment, or normal build 
notifications. Pi can run Maven as part of verifying its requested changes. 
Projects using `slingOsgiBundleBuild()` without this argument continue to build 
as before; they do not call LLMAO.
+`slingOsgiBundleBuild(llmaoOnly: true)` runs a separate Pi parent-POM upgrade 
job, not the normal Maven matrix, SonarCloud analysis, deployment, or normal 
build notifications. Pi can run Maven as part of verifying its requested 
changes; Jenkins independently checks the resulting parent version and runs 
Maven afterward. Projects using `slingOsgiBundleBuild()` without this argument 
continue to build as before; they do not call LLMAO.
 
 The dedicated job's stages reflect the local agent workflow:
 
@@ -52,9 +52,12 @@ The dedicated job's stages reflect the local agent workflow:
 2. **Configure Job** — apply scheduling, retention, and concurrency settings, 
then read and validate the task input.
 3. **Build Pi Image** — build or reuse the cached Docker image, without 
binding credentials.
 4. **Prepare Pi Task** — write the prompts, model settings, runtime script, 
and bundled extensions outside the checkout.
-5. **Run Pi Task** — run the agent, including skill loading, LLMAO requests, 
project edits, verification, and diff output. An unsuccessful task fails this 
stage.
-6. **Publish Token Usage** — stop any remaining container and summarize and 
archive observed usage, also after build or task failures.
-7. **Clean Up** — remove generated runtime inputs and temporary reports, 
leaving project edits untouched.
+5. **Determine Parent Target** — parse the original parent and independently 
select its latest numeric release from Maven Central. Preserve this expectation 
in a read-only agent input.
+6. **Run Pi Task** — run the agent, including skill loading, LLMAO requests, 
project edits, verification, and diff output. Process errors, incomplete 
responses, and cancellation fail this stage; assistant wording is diagnostic 
only. Skip Pi when the parent is already current.
+7. **Verify Parent Update** — require unchanged parent coordinates and the 
independently selected target version.
+8. **Verify Updated Project** — run `mvn -B -ntp clean verify` in a fresh 
container using the same pinned Java 21/Maven image, without Pi or the LLMAO 
credential. Maven's actual exit status determines this stage's result.
+9. **Publish Token Usage** — stop any remaining containers and summarize and 
archive observed usage, also after build, task, or verification failures.
+10. **Clean Up** — remove generated runtime inputs and temporary reports, 
leaving project edits untouched.
 
 ### A separate scheduled job
 
@@ -73,7 +76,7 @@ slingOsgiBundleBuild(
     llmaoOnly: true,
     llmaoRebuildFrequency: '', // Manual only; switch to '@weekly' for 
scheduling.
     llmaoModel: 'gemma4-26b',
-    llmaoInput: 'Update this project to the most recent released parent POM 
version and respond only with SUCCESS or FAILURE'
+    llmaoInput: 'Update this project to the most recent released parent POM 
version'
 )
 ```
 
@@ -89,19 +92,19 @@ These settings can be provided as library arguments or 
under `jenkins` in `.slin
 | `llmaoInput` | Empty | Task prompt to send to the Pi agent. |
 | `llmaoInputFile` | Empty | Workspace-relative UTF-8 prompt file to read 
instead of `llmaoInput`. |
 
-Provide either an input string or an input file; the library has no default 
task. The library allocates its own node and checks out the project; 
`llmaoInputFile` must exist in that checked-out workspace. Input files are not 
deleted. The checkout must be clean before Pi starts, so existing changes are 
never silently discarded. Avoid exposing secrets to the agent, and check the 
Models page for who can see prompts and source files.
+Provide either an input string or an input file describing the parent upgrade; 
the library has no default task. Deterministic verification supports 
`org.apache.sling:sling-parent` and `org.apache.sling:sling-bundle-parent` with 
explicit numeric release versions. Missing/unsupported parents, unresolved 
version properties, unavailable metadata, or metadata older than the current 
parent fail before Pi starts. The library allocates its own node and checks out 
the project; `llmaoInputFile` mu [...]
 
 `llmaoMaxTokens` replaces Pi's default 16,384-token output allowance for this 
custom model. A smaller allowance may help a request fit within the remaining 
PAT quota, but does not reset or increase that quota or cap the total tokens 
used by a multi-step job.
 
 The library runs [Pi](https://pi.dev) in Docker, configured for LLMAO's 
OpenAI-compatible endpoint at `https://llm.apache.org/v1`, with the selected 
model and temperature `0.0`. Pi can read, search, edit, and write project files 
and run shell commands. Automatic extensions, MCP, automatic project 
instructions, stored sessions, and compaction remain disabled.
 
-A bundled output logger is explicitly loaded. It writes Pi's assistant text, 
model-response stop reasons/errors, agent/request progress, tool starts and 
completion status, and tool-result tails to stderr in the Jenkins console. 
Tool-call summaries include shell commands, paths, and search patterns (up to 
2,000 characters), but not file contents supplied to edit/write tools. 
Assistant text and tool results are limited to their last 6,000 characters; 
successful file/skill-read contents, pr [...]
+A bundled output logger is explicitly loaded. It writes Pi's assistant text, 
model-response stop reasons/errors, agent/request progress, tool starts and 
completion status, and tool-result tails to stderr in the Jenkins console. 
Tool-call summaries include shell commands, paths, and search patterns (up to 
2,000 characters), but not file contents supplied to edit/write tools. 
Assistant text and tool results are limited to their last 6,000 characters; 
successful file/skill-read contents, pr [...]
 
 Pi retries transient provider/network errors, including HTTP 429 rate limits, 
up to 10 times per failed model turn at one-minute intervals. Retries stay in 
the same Pi session, preserving tool history and edits; provider-level retries 
remain disabled. This polls for quota recovery rather than parsing LLMAO's 
custom reset timestamp, and the existing job/process timeouts still apply.
 
 Each run fetches `skills/` from the `master` branch of [Apache Sling 
Whiteboard](https://github.com/apache/sling-whiteboard/tree/master/skills), 
including references and scripts. All its skills are explicitly available to 
Pi, including `update-sling-parent-pom`. The fetched Git revision is logged. 
Skills live outside the project checkout and do not appear in the diff. The 
parent-POM skill currently targets the latest supported release, not simply the 
current version plus one.
 
-Pi is instructed to respond only with `SUCCESS` after completing and verifying 
its update, or `FAILURE` otherwise, without staging, committing, pushing, 
switching branches, or altering Git metadata. The wrapper maps an exact 
`SUCCESS` and a successful Pi process to exit code `0`; `FAILURE`, 
empty/malformed answers, or execution errors return a nonzero exit code and 
fail the Jenkins job. The final response is kept separate from diff output, so 
strings in the diff cannot be mistaken for an [...]
+Pi is instructed to summarize its changes and verification, without staging, 
committing, pushing, switching branches, or altering Git metadata. It must use 
the independently selected parent target, which it can read but cannot 
overwrite. The job requires a normally completed Pi process, unchanged parent 
coordinates with the target version, and a successful independent Maven build. 
Neither `SUCCESS` nor `FAILURE` in assistant text or a diff decides the result. 
If the parent is already cur [...]
 
 The resulting Git diff is printed to stdout in the Jenkins build log, 
including partial changes on failure and new, non-ignored files without staging 
them. Project edits remain in the job workspace for inspection; no commit, 
push, or automatic recovery is performed. Generated input/configuration files 
are cleaned up, and sessions are not archived.
 
@@ -177,9 +180,9 @@ run_pi() {
 
 The wrapper forwards `LLMAO_PAT` for the LLMAO provider. For another provider, 
add only its required environment variable(s) as `--env NAME` options, or put 
only that provider's credential in the mounted `auth.json`. The container's Pi 
configuration and Maven home are temporary and are discarded when it exits.
 
-#### Exact non-interactive reproducer
+#### Agent-only non-interactive reproducer
 
-This follows the Jenkins prompt and flags: it selects the LLMAO model, uses 
the same read/search/edit/write/shell tools, disables automatic 
extensions/MCP/skills/prompts/context files, and explicitly loads the supplied 
skills. Change the task text if reproducing a different update.
+This selects the LLMAO model, uses the same read/search/edit/write/shell 
tools, disables automatic extensions/MCP/skills/prompts/context files, and 
explicitly loads the supplied skills. Unlike the Jenkins job, it uses plain 
print output and does not supply an independently selected parent target. 
Change the task text if reproducing a different update.
 
 ```sh
 run_pi \
@@ -187,11 +190,11 @@ run_pi \
   --print --tools read,bash,edit,write,grep,find,ls --no-session \
   --no-extensions --no-mcp --no-skills --no-prompt-templates \
   --skill /skills --no-context-files --no-approve \
-  --system-prompt 'You are a coding assistant working on the checked-out 
project. Follow the relevant supplied Apache Sling skills; use 
update-sling-parent-pom for upgrades to the most recent released parent POM 
version. Complete the requested update and its required verification. Java 21 
is the only available JDK; mvn and mvn21 both run Maven with Java 21. Do not 
stage, unstage, commit, push, switch branches, or change Git metadata. Respond 
with exactly SUCCESS if the update and verific [...]
-  'Update this project to the most recent released parent POM version and 
respond only with SUCCESS or FAILURE'
+  --system-prompt 'You are a coding assistant working on the checked-out 
project. Follow the relevant supplied Apache Sling skills; use 
update-sling-parent-pom for upgrades to the most recent released parent POM 
version. Complete the requested update and its required verification. Java 21 
is the only available JDK; mvn and mvn21 both run Maven with Java 21. Do not 
stage, unstage, commit, push, switch branches, or change Git metadata. Give a 
concise summary of the changes and verification [...]
+  'Update this project to the most recent released parent POM version'
 ```
 
-Pi prints its final response to stdout; inspect the resulting project changes 
on the host with `git -C "$PROJECT_DIR" diff` and `git -C "$PROJECT_DIR" status 
--short`. Git metadata is mounted read-only, so Pi cannot stage or commit 
changes.
+This local print-mode command does not include Jenkins' independent target 
selection and verification stages. Pi prints its final response to stdout; 
inspect the resulting project changes on the host with `git -C "$PROJECT_DIR" 
diff` and `git -C "$PROJECT_DIR" status --short`. Git metadata is mounted 
read-only, so Pi cannot stage or commit changes.
 
 #### Interactive Pi use
 
@@ -214,7 +217,7 @@ LLMAO builds that reach the image-build stage publish a 
console summary, add the
 A dedicated Pi extension collects gateway-reported usage across all model 
responses, including tool-calling turns and any usage reported on failed or 
retried turns. The report contains:
 
 - `provider`, `model`, and Jenkins `build` metadata (`job`, `number`, and 
`url`).
-- `result` (`SUCCESS`, `FAILURE`, or `INCOMPLETE` when no process exit code 
was returned), `exitCode`, Pi `state`, and `aborted`.
+- `result` (`SUCCESS` only after independent verification, `FAILURE` for a 
failed Pi/parent/build check, or `INCOMPLETE` when the checks did not finish), 
`exitCode` (the failing check's exit code, or zero after all checks pass), Pi 
`state`, and `aborted`.
 - `requests` (provider request attempts), `responsesWithUsage`, and 
`responsesWithoutUsage`. Counters are `null` and Pi state is `unknown` if the 
reporter could not produce a file.
 - `tokens`: `input` (uncached input), `output`, `cacheRead`, `cacheWrite`, and 
`total`. Cached input is counted separately, not added to input twice; 
reasoning tokens are already included in output.
 - `usageStatus`: `complete` if Pi settled without interruption and every 
response reported usage; `partial` if only some usage is available or the run 
was interrupted; `unavailable` if no valid usage was reported. `tokens` is 
`null` for unavailable usage, rather than a misleading zero. A gateway-reported 
zero remains zero.
@@ -231,7 +234,7 @@ The library builds `sling-llmao-pi:1.1.0-jdk21-v3` from its 
[Dockerfile](resourc
 
 Java 21 is the only available JDK. Both `mvn` and the `mvn21` wrapper use it, 
and Pi is told to use these commands. No `mvn8`, `mvn11`, or `mvn17` wrappers 
are installed. `MAVEN_ARGS='-B -ntp'` applies batch mode and suppresses 
download-progress output for both Maven commands, even when a skill invokes 
`mvn clean verify` without those flags.
 
-Image building happens before credential binding. The container receives 
`LLMAO_PAT` from the Jenkins environment, not as a token value in its command 
line or configuration files. It runs as the Jenkins user's UID/GID with 
writable project and usage-report mounts and read-only `.git` and input mounts. 
Its private configuration, skills checkout, and Maven home/cache live in 
container-local `/tmp`; host credentials and the Docker socket are not mounted. 
The Pi process is bounded to 25 minu [...]
+Image building and target selection happen before credential binding. The Pi 
container receives `LLMAO_PAT` from the Jenkins environment, not as a token 
value in its command line or configuration files. It runs as the Jenkins user's 
UID/GID with writable project and usage-report mounts and read-only `.git` and 
input mounts. Its private configuration, skills checkout, and Maven home/cache 
live in container-local `/tmp`; host credentials and the Docker socket are not 
mounted. Verification  [...]
 
 ### Testing
 
@@ -241,6 +244,8 @@ Run offline checks from the repository root:
 groovy tests/llmao.groovy
 node tests/tool-output-logger.mjs
 node tests/usage-reporter.mjs
+node tests/pi-events.mjs
+python3 tests/verification.py
 python3 tests/pi-docker.py
 ```
 
@@ -249,6 +254,7 @@ These stub Jenkins steps and Docker/Pi output; they require 
no credentials or LL
 ```sh
 docker build --tag sling-llmao-pi:1.1.0-jdk21-v3 
resources/org/apache/sling/jenkins/llmao
 python3 tests/pi-docker.py --integration
+python3 tests/verification.py --integration
 ```
 
-The integration test exercises skill discovery/loading, real 
read/edit/write/find/shell tool calls, assistant/progress/tool console output 
with private read contents, Java 21 with both Maven commands and the absence of 
older JDKs/wrappers, Maven batch-mode settings, environment-token 
authentication, model selection, tracked/new-file diffs, SUCCESS/FAILURE 
mapping, malformed/empty answers, API errors, multi-turn token/cache totals, 
retries, missing/zero usage, cancellation, and container  [...]
+The offline tests cover latest-release selection, XML namespaces, 
unchanged/wrong parent versions and coordinates, already-current parents, 
credential-free verification mounts and exit codes, lifecycle failures, 
independent-stage failures, usage publishing, and cleanup. The Pi integration 
test exercises skill discovery/loading, real read/edit/write/find/shell tool 
calls, assistant/progress/tool console output with private read contents, Java 
21 with both Maven commands and the absence of [...]
diff --git a/resources/org/apache/sling/jenkins/llmao/parent-pom.py 
b/resources/org/apache/sling/jenkins/llmao/parent-pom.py
new file mode 100644
index 0000000..99360a1
--- /dev/null
+++ b/resources/org/apache/sling/jenkins/llmao/parent-pom.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Select and check a released Sling parent without executing project code."""
+
+import json
+from pathlib import Path
+import re
+import sys
+from urllib.request import urlopen
+import xml.etree.ElementTree as ET
+
+
+def read_parent(path):
+    root = ET.parse(path).getroot()
+    if root.tag == "{http://maven.apache.org/POM/4.0.0}project":
+        namespace = "{http://maven.apache.org/POM/4.0.0}";
+    elif root.tag == "project":
+        namespace = ""
+    else:
+        raise ValueError("Expected a Maven project document")
+    parent = root.find(f"{namespace}parent")
+    if parent is None:
+        raise ValueError("pom.xml has no direct parent")
+    result = {key: (parent.findtext(f"{namespace}{key}") or "").strip()
+              for key in ("groupId", "artifactId", "version")}
+    if result["groupId"] != "org.apache.sling" or result["artifactId"] not in (
+            "sling-parent", "sling-bundle-parent"):
+        raise ValueError("Expected org.apache.sling:sling-parent or 
sling-bundle-parent")
+    if not re.fullmatch(r"[0-9]+", result["version"]):
+        raise ValueError("The Sling parent must have an explicit numeric 
release version")
+    return result
+
+
+def select_target(parent, metadata):
+    # Sling parent release versions are integers. Ignore snapshots and other
+    # qualifiers rather than relying on lexical order or metadata's <latest>.
+    versions = [int(version.text.strip())
+                for version in 
ET.fromstring(metadata).findall("./versioning/versions/version")
+                if version.text and re.fullmatch(r"[0-9]+", 
version.text.strip())]
+    if not versions:
+        raise ValueError("Maven Central metadata contains no released Sling 
parent versions")
+    target = str(max(versions))
+    if int(target) < int(parent["version"]):
+        raise ValueError("Maven Central's latest release is older than the 
current parent")
+    return dict(parent, targetVersion=target, updateRequired=target != 
parent["version"])
+
+
+def check_parent(parent, expected):
+    for key in ("groupId", "artifactId"):
+        if parent[key] != expected[key]:
+            raise ValueError(f"Parent {key} changed: expected {expected[key]}, 
found {parent[key]}")
+    if parent["version"] != expected["targetVersion"]:
+        raise ValueError(f"Expected parent version 
{expected['targetVersion']}, found {parent['version']} "
+                         f"(original version {expected['version']})")
+    if expected["updateRequired"] and parent["version"] == expected["version"]:
+        raise ValueError("The parent version was not updated")
+
+
+def main():
+    action = sys.argv[1]
+    parent = read_parent("/workspace/pom.xml")
+    if action == "prepare-parent":
+        url = ("https://repo.maven.apache.org/maven2/org/apache/sling/";
+               f"{parent['artifactId']}/maven-metadata.xml")
+        with urlopen(url, timeout=60) as response:
+            expected = select_target(parent, response.read())
+        print(json.dumps(expected))
+    elif action == "check-parent":
+        expected = 
json.loads(Path("/inputs/expected-parent.json").read_text(encoding="utf-8"))
+        check_parent(parent, expected)
+        if expected["updateRequired"]:
+            print(f"Parent update verified: 
{parent['groupId']}:{parent['artifactId']} "
+                  f"{expected['version']} -> {parent['version']}")
+        else:
+            print(f"No parent update needed: already at released version 
{parent['version']}")
+    else:
+        raise ValueError(f"Unknown parent check: {action}")
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except Exception as error:
+        print(f"Parent POM check failed: {error}", file=sys.stderr)
+        sys.exit(1)
diff --git a/resources/org/apache/sling/jenkins/llmao/pi-events.mjs 
b/resources/org/apache/sling/jenkins/llmao/pi-events.mjs
index b463718..dd057c2 100644
--- a/resources/org/apache/sling/jenkins/llmao/pi-events.mjs
+++ b/resources/org/apache/sling/jenkins/llmao/pi-events.mjs
@@ -20,8 +20,8 @@
 import { spawn } from 'node:child_process';
 import fs from 'node:fs';
 
-// Consume the JSONL protocol continuously. Progress belongs on stderr; only
-// the final SUCCESS/FAILURE decision belongs on stdout for run-docker.sh.
+// Consume JSONL lifecycle events without interpreting assistant prose.
+// The bundled output logger handles progress and tool/assistant diagnostics.
 const secret = process.env.LLMAO_PAT;
 function sanitize(value, limit = 6000) {
     let text = String(value ?? '');
@@ -32,22 +32,16 @@ function sanitize(value, limit = 6000) {
 function log(value, limit) {
     process.stderr.write(`[Pi] ${sanitize(value, limit)}\n`);
 }
-function textBlocks(content) {
-    if (typeof content === 'string') return content;
-    return (Array.isArray(content) ? content : []).filter(block => block?.type 
=== 'text').map(block => block.text).join('\n');
-}
-
 let lastAssistant;
 let settled = false;
 let aborted = false;
 let protocolError = false;
 let quotaReported = false;
-const tools = new Map();
 
 function diagnostic(text) {
     if (!quotaReported && /\b429\b|rate limit exceeded/i.test(text)) {
         quotaReported = true;
-        log('LLMAO quota/rate limit rejected the request. Wait for the 
reported reset or request a higher limit; retries are disabled.');
+        log('LLMAO quota/rate limit rejected the request. Pi retry settings 
and the job timeout still apply.');
     }
     log(text);
 }
@@ -56,52 +50,25 @@ function event(record) {
     switch (record.type) {
         case 'agent_start':
             settled = false;
-            log('Agent started');
-            break;
-        case 'turn_start':
-            log('Requesting the next model response');
-            break;
-        case 'tool_execution_start': {
-            tools.set(record.toolCallId, record.toolName);
-            const detail = record.toolName === 'bash' ? record.args?.command : 
record.args?.path ?? record.args?.pattern ?? '';
-            log(`Tool ${record.toolName}: ${detail}`, 2000);
+            aborted = false;
+            lastAssistant = undefined;
             break;
-        }
-        case 'tool_execution_end': {
-            const name = record.toolName ?? tools.get(record.toolCallId) ?? 
'unknown';
-            log(`Tool ${name}: ${record.isError ? 'ERROR' : 
'OK'}${record.durationMs === undefined ? '' : ` (${record.durationMs} ms)`}`);
-            // Do not dump source/skills/prompts. Shell output and tool errors
-            // carry the build diagnostics; keep their tail bounded and 
redacted.
-            if (name === 'bash' || record.isError) {
-                const output = textBlocks(record.result?.content);
-                if (output) diagnostic(output);
-            }
-            tools.delete(record.toolCallId);
-            break;
-        }
         case 'message_end': {
             const message = record.message;
             if (message?.role !== 'assistant') break;
             lastAssistant = message;
-            if (message.usage) {
-                const usage = message.usage;
-                log(`Tokens: input=${usage.input ?? 0}, output=${usage.output 
?? 0}, cacheRead=${usage.cacheRead ?? 0}, cacheWrite=${usage.cacheWrite ?? 0}`);
-            }
-            log(`Model response ended: ${message.stopReason ?? 'unknown'}`);
             if (message.errorMessage) diagnostic(message.errorMessage);
-            if (message.stopReason === 'length') log('Model output was 
truncated. Increase llmaoMaxTokens only if the gateway quota allows it.');
             break;
         }
         case 'agent_settled':
             settled = true;
             aborted = Boolean(record.aborted);
-            log(aborted ? 'Agent aborted' : 'Agent settled');
             break;
     }
 }
 
 // Split only on LF, not Unicode line separators (which can occur in JSON).
-function lines(stream, consume) {
+function lines(stream, consume, keepEmpty = false) {
     let pending = '';
     stream.setEncoding('utf8');
     stream.on('data', chunk => {
@@ -110,7 +77,7 @@ function lines(stream, consume) {
         while ((newline = pending.indexOf('\n')) !== -1) {
             const line = pending.slice(0, newline).replace(/\r$/, '');
             pending = pending.slice(newline + 1);
-            if (line) consume(line);
+            if (line || keepEmpty) consume(line);
         }
     });
     stream.on('end', () => { if (pending) consume(pending); });
@@ -134,20 +101,16 @@ lines(child.stdout, line => {
         log('Invalid Pi JSON event; the run will fail.');
     }
 });
-lines(child.stderr, diagnostic);
+lines(child.stderr, text => process.stderr.write(`${sanitize(text)}\n`), true);
 child.on('error', error => {
     diagnostic(`Could not start Pi: ${error.message}`);
     protocolError = true;
 });
 child.on('close', (code, signal) => {
     if (signal) log(`Pi terminated by ${signal}`);
-    const answer = textBlocks(lastAssistant?.content).trim();
     const hasToolCalls = Array.isArray(lastAssistant?.content) && 
lastAssistant.content.some(block => block?.type === 'toolCall');
     const complete = code === 0 && !signal && !protocolError && settled && 
!aborted &&
-        lastAssistant?.stopReason === 'stop' && !hasToolCalls;
-    const success = complete && answer === 'SUCCESS';
-    if (complete && answer !== 'SUCCESS' && answer !== 'FAILURE') log('Final 
model response was not exactly SUCCESS or FAILURE.');
+        lastAssistant?.stopReason === 'stop' && !lastAssistant?.errorMessage 
&& !hasToolCalls;
     if (!complete) log('Pi did not complete a successful, settled model 
response.');
-    process.stdout.write(`${success ? 'SUCCESS' : 'FAILURE'}\n`);
-    process.exitCode = success ? 0 : (code && code > 0 ? code : 1);
+    process.exitCode = complete ? 0 : (code && code > 0 ? code : 1);
 });
diff --git a/resources/org/apache/sling/jenkins/llmao/run-docker.sh 
b/resources/org/apache/sling/jenkins/llmao/run-docker.sh
index 391dbf9..a848301 100644
--- a/resources/org/apache/sling/jenkins/llmao/run-docker.sh
+++ b/resources/org/apache/sling/jenkins/llmao/run-docker.sh
@@ -32,7 +32,7 @@ fi
 
 status=0
 mkdir -p "$LLMAO_REPORT_DIR"
-result=$(docker run --rm --init \
+docker run --rm --init \
     --name "$LLMAO_CONTAINER" \
     --user "$(id -u):$(id -g)" \
     --read-only --tmpfs /tmp:rw,nosuid,nodev,mode=1777 \
@@ -42,21 +42,10 @@ result=$(docker run --rm --init \
     --mount "type=bind,source=$LLMAO_WORKSPACE,target=/workspace" \
     --mount 
"type=bind,source=$LLMAO_WORKSPACE/.git,target=/workspace/.git,readonly" \
     --workdir /workspace \
-    "$LLMAO_IMAGE" sh /inputs/run-pi.sh) || status=$?
+    "$LLMAO_IMAGE" sh /inputs/run-pi.sh || status=$?
 
-result=$(printf '%s' "$result" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
-if [ "$status" -eq 0 ] && [ "$result" = SUCCESS ]; then
-    echo SUCCESS
-else
-    echo FAILURE
-    if [ "$status" -eq 0 ]; then
-        if [ "$result" != FAILURE ]; then
-            echo 'Pi did not return exactly SUCCESS or FAILURE.' >&2
-            printf 'Unexpected Pi response: <%s>\n' "$result" >&2
-        fi
-        status=1
-    fi
-fi
+# Assistant wording is diagnostic only. Parent and Maven checks in later
+# stages decide whether the requested update actually succeeded.
 
 # Report partial edits on failure as well. Include new files without staging
 # them, and disable external diff drivers/text converters from the checkout.
diff --git a/resources/org/apache/sling/jenkins/llmao/run-pi.sh 
b/resources/org/apache/sling/jenkins/llmao/run-pi.sh
index db8a1f4..a4dd3fa 100644
--- a/resources/org/apache/sling/jenkins/llmao/run-pi.sh
+++ b/resources/org/apache/sling/jenkins/llmao/run-pi.sh
@@ -40,11 +40,11 @@ git -C "$HOME/sling-whiteboard" sparse-checkout set skills 
>&2
 printf 'Sling skills revision: ' >&2
 git -C "$HOME/sling-whiteboard" rev-parse HEAD >&2
 
-# Print mode keeps the final assistant text on stdout for the result check.
-# The output logger sends assistant/progress/tool diagnostics to stderr.
-exec timeout --kill-after=5s 1500s pi \
+# Consume structured lifecycle events to detect incomplete/API-error runs,
+# independently of the final assistant's wording. The logger uses stderr.
+exec timeout --kill-after=5s 1500s node /inputs/pi-events.mjs \
     --provider llmao --model "$LLMAO_MODEL" \
-    --print --tools read,bash,edit,write,grep,find,ls --no-session \
+    --print --mode json --tools read,bash,edit,write,grep,find,ls --no-session 
\
     --no-extensions --no-mcp --no-skills --no-prompt-templates \
     --extension /inputs/tool-output-logger.mjs \
     --extension /inputs/usage-reporter.mjs \
diff --git a/resources/org/apache/sling/jenkins/llmao/run-verification.sh 
b/resources/org/apache/sling/jenkins/llmao/run-verification.sh
new file mode 100644
index 0000000..d80b8ac
--- /dev/null
+++ b/resources/org/apache/sling/jenkins/llmao/run-verification.sh
@@ -0,0 +1,48 @@
+#!/bin/bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+set +x
+set -eu
+export GIT_OPTIONAL_LOCKS=0
+
+if [ "$LLMAO_CHECK" = prepare-parent ]; then
+    checkout_status=$(git -C "$LLMAO_WORKSPACE" status --porcelain)
+    if [ -n "$checkout_status" ]; then
+        echo 'The project checkout must be clean before selecting the parent 
target.' >&2
+        exit 1
+    fi
+fi
+
+case "$LLMAO_CHECK" in
+    prepare-parent|check-parent) command=(python3 /inputs/parent-pom.py 
"$LLMAO_CHECK") ;;
+    verify-build) command=(sh /inputs/verify-build.sh) ;;
+    *) echo 'Unknown verification action.' >&2; exit 1 ;;
+esac
+
+# Use the same pinned image, but a new process/home with no agent or PAT.
+container="${LLMAO_CONTAINER}-verify"
+trap 'docker rm --force "$container" >/dev/null 2>&1 || true' EXIT HUP INT TERM
+docker run --rm --init \
+    --name "$container" \
+    --user "$(id -u):$(id -g)" \
+    --read-only --tmpfs /tmp:rw,nosuid,nodev,mode=1777 \
+    --mount "type=bind,source=$(pwd)/inputs,target=/inputs,readonly" \
+    --mount "type=bind,source=$LLMAO_WORKSPACE,target=/workspace" \
+    --mount 
"type=bind,source=$LLMAO_WORKSPACE/.git,target=/workspace/.git,readonly" \
+    --workdir /workspace \
+    "$LLMAO_IMAGE" "${command[@]}"
diff --git a/resources/org/apache/sling/jenkins/llmao/verify-build.sh 
b/resources/org/apache/sling/jenkins/llmao/verify-build.sh
new file mode 100644
index 0000000..8140245
--- /dev/null
+++ b/resources/org/apache/sling/jenkins/llmao/verify-build.sh
@@ -0,0 +1,28 @@
+#!/bin/sh
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+set -eu
+export HOME=/tmp/verify-home
+export MAVEN_CONFIG="$HOME/.m2"
+export MAVEN_OPTS="-Duser.home=$HOME"
+export MAVEN_ARGS=''
+export GIT_OPTIONAL_LOCKS=0
+mkdir -p "$MAVEN_CONFIG"
+git config --global --add safe.directory /workspace
+# No pipes: Jenkins receives Maven's actual exit status and full diagnostics.
+exec mvn -B -ntp clean verify
diff --git a/vars/slingOsgiBundleBuild.groovy b/vars/slingOsgiBundleBuild.groovy
index 3bd83f4..9c3e2a3 100644
--- a/vars/slingOsgiBundleBuild.groovy
+++ b/vars/slingOsgiBundleBuild.groovy
@@ -154,7 +154,7 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
     ]
 
     node(globalConfig.mainNodeLabel) {
-        timeout(time: 30, unit: 'MINUTES') {
+        timeout(time: 45, unit: 'MINUTES') {
             stage('Checkout and Load Configuration') {
                 checkout scm
                 if ( fileExists('.sling-module.json') ) {
@@ -223,7 +223,10 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
                                 writeFile file: 'run-pi.sh', text: 
libraryResource('org/apache/sling/jenkins/llmao/run-pi.sh')
                                 writeFile file: 'tool-output-logger.mjs', 
text: libraryResource('org/apache/sling/jenkins/llmao/tool-output-logger.mjs')
                                 writeFile file: 'usage-reporter.mjs', text: 
libraryResource('org/apache/sling/jenkins/llmao/usage-reporter.mjs')
-                                writeFile file: 'system-prompt.txt', text: 
'You are a coding assistant working on the checked-out project. Follow the 
relevant supplied Apache Sling skills; use update-sling-parent-pom for upgrades 
to the most recent released parent POM version. Complete the requested update 
and its required verification. Java 21 is the only available JDK; mvn and mvn21 
both run Maven with Java 21. Do not stage, unstage, commit, push, switch 
branches, or change Git metadat [...]
+                                writeFile file: 'pi-events.mjs', text: 
libraryResource('org/apache/sling/jenkins/llmao/pi-events.mjs')
+                                writeFile file: 'parent-pom.py', text: 
libraryResource('org/apache/sling/jenkins/llmao/parent-pom.py')
+                                writeFile file: 'verify-build.sh', text: 
libraryResource('org/apache/sling/jenkins/llmao/verify-build.sh')
+                                writeFile file: 'system-prompt.txt', text: 
'You are a coding assistant working on the checked-out project. Follow the 
relevant supplied Apache Sling skills; use update-sling-parent-pom for upgrades 
to the most recent released parent POM version. Read 
/inputs/expected-parent.json for the independently selected parent coordinates 
and targetVersion; keep the parent coordinates unchanged and upgrade to that 
target version. Complete the requested update and its [...]
                                 writeFile file: 'prompt_input.txt', text: 
contextData, encoding: 'UTF-8'
                                 writeJSON file: 'models.json', json: [
                                     providers: [llmao: [
@@ -252,18 +255,60 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
                                 ]
                             }
                         }
+                        def expectedParent
+                        stage('Determine Parent Target') {
+                            withEnv(['LLMAO_CHECK=prepare-parent']) {
+                                expectedParent = readJSON(text: sh(
+                                    script: 
libraryResource('org/apache/sling/jenkins/llmao/run-verification.sh'),
+                                    returnStdout: true
+                                ).trim(), returnPojo: true)
+                            }
+                            // Inputs are mounted read-only for Pi; its 
writable reports
+                            // cannot change the original parent or selected 
target.
+                            writeJSON file: 'inputs/expected-parent.json', 
json: expectedParent
+                            echo "Parent target: 
${expectedParent.groupId}:${expectedParent.artifactId} 
${expectedParent.version} -> ${expectedParent.targetVersion}"
+                        }
                         stage('Run Pi Task') {
-                            withCredentials([string(credentialsId: 
'llmao-pat', variable: 'LLMAO_PAT')]) {
-                                updateStatus = sh(
-                                    script: 
libraryResource('org/apache/sling/jenkins/llmao/run-docker.sh'),
+                            if ( expectedParent.updateRequired ) {
+                                def piStatus
+                                withCredentials([string(credentialsId: 
'llmao-pat', variable: 'LLMAO_PAT')]) {
+                                    piStatus = sh(
+                                        script: 
libraryResource('org/apache/sling/jenkins/llmao/run-docker.sh'),
+                                        returnStatus: true
+                                    )
+                                }
+                                if ( piStatus != 0 ) {
+                                    updateStatus = piStatus
+                                    error("Pi did not complete normally (exit 
code ${piStatus}).")
+                                }
+                                echo 'Pi completed normally; independently 
verifying the update.'
+                            } else {
+                                echo 'No parent update needed; skipping Pi and 
verifying the existing project.'
+                            }
+                        }
+                        stage('Verify Parent Update') {
+                            def parentStatus
+                            withEnv(['LLMAO_CHECK=check-parent']) {
+                                parentStatus = sh(
+                                    script: 
libraryResource('org/apache/sling/jenkins/llmao/run-verification.sh'),
                                     returnStatus: true
                                 )
                             }
-                            // Attribute update failures to the task, not a 
later response stage.
-                            if ( updateStatus == 0 ) {
-                                echo 'Pi completed the project update 
successfully.'
-                            } else {
-                                error("Pi could not complete the project 
update (exit code ${updateStatus}).")
+                            if ( parentStatus != 0 ) {
+                                updateStatus = parentStatus
+                                error("Parent POM update verification failed 
(exit code ${parentStatus}).")
+                            }
+                        }
+                        stage('Verify Updated Project') {
+                            timeout(time: 15, unit: 'MINUTES') {
+                                withEnv(['LLMAO_CHECK=verify-build']) {
+                                    updateStatus = sh(
+                                        script: 
libraryResource('org/apache/sling/jenkins/llmao/run-verification.sh'),
+                                        returnStatus: true
+                                    )
+                                }
+                                if ( updateStatus != 0 )
+                                    error("Independent mvn clean verify failed 
(exit code ${updateStatus}).")
                             }
                         }
                     } finally {
@@ -273,6 +318,7 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
                                 sh '''
                                     set +x
                                     docker rm --force "$LLMAO_CONTAINER" 
>/dev/null 2>&1 || true
+                                    docker rm --force 
"${LLMAO_CONTAINER}-verify" >/dev/null 2>&1 || true
                                 '''
                                 dir(reportDirectory) {
                                     publishLlmaoUsage(llmaoConfig.llmaoModel, 
updateStatus)
@@ -284,7 +330,8 @@ def runLlmaoOnlyBuild(def globalConfig, Map params) {
                                     set +x
                                     rm -f image/Dockerfile inputs/models.json 
inputs/settings.json \\
                                       inputs/system-prompt.txt 
inputs/prompt_input.txt inputs/run-pi.sh \\
-                                      inputs/tool-output-logger.mjs 
inputs/usage-reporter.mjs
+                                      inputs/tool-output-logger.mjs 
inputs/usage-reporter.mjs inputs/pi-events.mjs \\
+                                      inputs/parent-pom.py 
inputs/expected-parent.json inputs/verify-build.sh
                                     rm -f "$LLMAO_REPORT_DIR/llmao-usage.json" 
"$LLMAO_REPORT_DIR/llmao-usage.json.tmp"
                                     rmdir "$LLMAO_REPORT_DIR" >/dev/null 2>&1 
|| true
                                 '''

Reply via email to